origami_env / viewer /index.html
praveen287's picture
Update viewer
fdeaf3f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Origami RL</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #faf9f7;
--surface: #ffffff;
--border: #e8e4df;
--border-hover: #c9c3ba;
--text: #1a1816;
--text-secondary: #7a756e;
--text-tertiary: #a9a49d;
--mountain: #c0392b;
--valley: #2471a3;
--boundary: #1a1816;
--accent: #c0392b;
--accent-soft: #f5eeec;
--success: #27ae60;
--code-bg: #f3f1ee;
--shadow-sm: 0 1px 3px rgba(26,24,22,0.04);
--shadow-md: 0 4px 16px rgba(26,24,22,0.06);
--shadow-lg: 0 8px 32px rgba(26,24,22,0.08);
--radius: 12px;
--radius-sm: 8px;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* --- VIEW MANAGEMENT --- */
.view { display: none; }
.view.active { display: block; }
#detail-view.active { display: flex; }
/* --- HEADER --- */
header {
padding: 28px 48px 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.header-left {
display: flex;
align-items: baseline;
gap: 14px;
}
header h1 {
font-family: 'Instrument Serif', serif;
font-size: 26px;
font-weight: 400;
letter-spacing: -0.02em;
}
.header-tag {
font-size: 11px;
color: var(--text-tertiary);
font-weight: 400;
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 4px;
}
.nav-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 16px;
font-family: inherit;
font-weight: 400;
transition: all 0.2s;
letter-spacing: 0.01em;
}
.nav-btn:hover {
border-color: var(--border-hover);
color: var(--text);
background: var(--bg);
}
/* ═══════════════════════════════════════════════════════════
VIEW 1 β€” LANDING PAGE
═══════════════════════════════════════════════════════════ */
#landing-view {
min-height: 100vh;
}
.landing-content {
max-width: 820px;
margin: 0 auto;
padding: 48px 48px 80px;
}
/* Hero */
.hero {
margin-bottom: 56px;
}
.hero p {
font-size: 18px;
line-height: 1.6;
color: var(--text-secondary);
font-weight: 300;
max-width: 640px;
}
/* Section headings */
.section {
margin-bottom: 48px;
}
.section h2 {
font-family: 'Instrument Serif', serif;
font-size: 22px;
font-weight: 400;
margin-bottom: 20px;
letter-spacing: -0.01em;
}
.section h3 {
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
margin-bottom: 16px;
}
/* How it works β€” 3-step flow */
.steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.step {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 22px;
}
.step-num {
font-family: 'Instrument Serif', serif;
font-size: 28px;
color: var(--accent);
line-height: 1;
margin-bottom: 8px;
}
.step-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.step-desc {
font-size: 13px;
color: var(--text-secondary);
font-weight: 300;
line-height: 1.5;
}
/* API Reference */
.api-group {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 12px;
}
.api-row {
padding: 14px 20px;
display: flex;
align-items: baseline;
gap: 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.api-row:last-child {
border-bottom: none;
}
.api-method {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 500;
color: var(--accent);
background: var(--accent-soft);
padding: 2px 7px;
border-radius: 4px;
white-space: nowrap;
min-width: 48px;
text-align: center;
}
.api-method.ws {
color: var(--valley);
background: #eaf2f8;
}
.api-path {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text);
}
.api-desc {
color: var(--text-secondary);
font-weight: 300;
margin-left: auto;
white-space: nowrap;
}
/* Schema / FOLD format */
.schema-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.schema-row {
padding: 12px 20px;
display: flex;
gap: 12px;
border-bottom: 1px solid var(--border);
font-size: 13px;
line-height: 1.5;
}
.schema-row:last-child {
border-bottom: none;
}
.schema-field {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text);
font-weight: 500;
min-width: 160px;
flex-shrink: 0;
}
.schema-type {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-tertiary);
min-width: 130px;
flex-shrink: 0;
}
.schema-desc {
color: var(--text-secondary);
font-weight: 300;
}
.schema-tag {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
.schema-tag.required {
color: var(--accent);
background: var(--accent-soft);
}
.schema-tag.optional {
color: var(--text-tertiary);
background: var(--code-bg);
}
/* Tasks table β€” removed */
.tasks-table .task-name {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
}
.tasks-table .task-diff {
display: inline-flex;
gap: 3px;
}
.tasks-table .task-diff .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
}
.tasks-table .task-diff .dot.empty {
background: var(--border);
}
.tasks-table .task-desc {
color: var(--text-secondary);
font-weight: 300;
}
/* Code block */
.code-block {
background: #2b2926;
border-radius: var(--radius);
overflow: hidden;
}
.code-header {
padding: 10px 20px;
font-size: 11px;
color: #a09a92;
border-bottom: 1px solid #3d3a36;
font-family: 'JetBrains Mono', monospace;
display: flex;
align-items: center;
justify-content: space-between;
}
.code-block pre {
padding: 20px;
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
color: #e8e4df;
}
.copy-btn {
background: none;
border: 1px solid #3d3a36;
border-radius: 4px;
color: #a09a92;
cursor: pointer;
padding: 4px 8px;
display: flex;
align-items: center;
gap: 5px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
transition: all 0.2s;
}
.copy-btn:hover {
border-color: #666;
color: #e8e4df;
}
.copy-btn.copied {
border-color: var(--success);
color: var(--success);
}
.code-block .kw { color: #e67e22; }
.code-block .str { color: #27ae60; }
.code-block .cmt { color: #7a756e; }
.code-block .fn { color: #d4a96a; }
.code-block .num { color: #c0392b; }
/* Inline code */
.section code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--code-bg);
padding: 2px 6px;
border-radius: 4px;
color: var(--text);
}
/* WebSocket message blocks */
.ws-block {
background: var(--code-bg);
border-radius: var(--radius-sm);
padding: 12px 16px;
margin: 8px 0;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text);
overflow-x: auto;
}
.ws-label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 4px;
font-family: 'DM Sans', sans-serif;
}
/* ═══════════════════════════════════════════════════════════
VIEW 2 β€” GRID
═══════════════════════════════════════════════════════════ */
#grid-view {
min-height: 100vh;
}
.grid-content {
padding: 32px 48px 64px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
max-width: 1280px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
}
.card:hover {
border-color: var(--border-hover);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.card-canvas {
width: 100%;
height: 200px;
background: var(--bg);
border-bottom: 1px solid var(--border);
position: relative;
}
.card-canvas canvas {
width: 100% !important;
height: 100% !important;
}
.card-info {
padding: 16px 20px;
}
.card-name {
font-family: 'Instrument Serif', serif;
font-size: 18px;
margin-bottom: 4px;
}
.card-desc {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
font-weight: 300;
}
.card-score {
display: flex;
align-items: center;
gap: 10px;
}
.score-bar-bg {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.score-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.score-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
min-width: 36px;
text-align: right;
}
/* ═══════════════════════════════════════════════════════════
VIEW 3 β€” DETAIL
═══════════════════════════════════════════════════════════ */
#detail-view {
height: 100vh;
flex-direction: column;
}
.detail-header {
padding: 20px 32px;
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.back-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 14px;
font-family: inherit;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--border-hover);
color: var(--text);
}
.detail-title {
font-family: 'Instrument Serif', serif;
font-size: 22px;
font-weight: 400;
}
.detail-subtitle {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 300;
margin-left: auto;
}
.detail-body {
flex: 1;
display: grid;
grid-template-columns: 1fr 1.5fr;
min-height: 0;
}
.panel-2d {
border-right: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
}
.panel-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
padding: 16px 24px 8px;
font-weight: 500;
}
.crease-canvas {
flex: 1;
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.crease-canvas svg {
max-width: 100%;
max-height: 100%;
}
.legend {
padding: 12px 24px 20px;
display: flex;
gap: 20px;
font-size: 11px;
color: var(--text-secondary);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-swatch {
width: 16px;
height: 2px;
border-radius: 1px;
}
.panel-3d {
background: var(--bg);
position: relative;
}
.panel-3d canvas {
width: 100% !important;
height: 100% !important;
}
/* Slider */
.slider-bar {
padding: 16px 32px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 20px;
}
.slider-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 300;
min-width: 40px;
}
.slider-track {
flex: 1;
position: relative;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
background: var(--border);
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--surface);
border: 2px solid var(--accent);
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: transform 0.15s;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.slider-value {
font-size: 13px;
font-weight: 500;
color: var(--text);
min-width: 40px;
text-align: right;
}
/* Metrics */
.metrics-bar {
padding: 12px 32px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
gap: 40px;
}
.metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
}
.metric-value {
font-size: 16px;
font-weight: 500;
}
.metric-value.high { color: var(--success); }
.metric-value.mid { color: #e67e22; }
.metric-value.low { color: var(--accent); }
/* ═══════════════════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════════════ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fadeIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.card:nth-child(1) { animation-delay: 0.05s; }
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.15s; }
.card:nth-child(4) { animation-delay: 0.2s; }
.section {
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.section:nth-child(1) { animation-delay: 0.05s; }
.section:nth-child(2) { animation-delay: 0.1s; }
.section:nth-child(3) { animation-delay: 0.15s; }
.section:nth-child(4) { animation-delay: 0.2s; }
.section:nth-child(5) { animation-delay: 0.25s; }
.section:nth-child(6) { animation-delay: 0.3s; }
.section:nth-child(7) { animation-delay: 0.35s; }
/* Responsive */
@media (max-width: 680px) {
header { padding: 20px 24px; }
.landing-content { padding: 32px 24px 64px; }
.grid-content { padding: 24px; }
.steps { grid-template-columns: 1fr; }
.schema-row { flex-direction: column; gap: 4px; }
.schema-type { min-width: 0; }
.schema-field { min-width: 0; }
.api-row { flex-direction: column; gap: 6px; }
.api-desc { margin-left: 0; }
}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════
HEADER (shared, content swapped by JS)
═══════════════════════════════════════════════════════════ -->
<header id="main-header">
<div class="header-left">
<h1 id="header-title">Origami RL</h1>
<span class="header-tag" id="header-tag">environment</span>
</div>
<button class="nav-btn" id="header-nav" onclick="showView('grid')">Origamis &rarr;</button>
</header>
<!-- ═══════════════════════════════════════════════════════════
VIEW 1 β€” LANDING PAGE
═══════════════════════════════════════════════════════════ -->
<div id="landing-view" class="view active">
<div class="landing-content">
<!-- Hero -->
<div class="hero section">
<p>OpenEnv RL environment for origami folding. Submit FOLD crease patterns, get physics simulation + shape similarity reward.</p>
<p style="margin-top:12px;font-size:14px;color:var(--text-tertiary);font-weight:300">Rewards inspired by AlphaFold &mdash; chamfer distance shape matching with rotational alignment across 24 orientations.</p>
</div>
<!-- How It Works -->
<div class="section">
<h2>How It Works</h2>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-title">Reset</div>
<div class="step-desc">Pick a task &rarr; get target shape and flat paper starting state</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-title">Step</div>
<div class="step-desc">Submit a FOLD JSON crease pattern describing your fold</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-title">Reward</div>
<div class="step-desc">shape_similarity &times; 20.0 &mdash; score from 0 to 20</div>
</div>
</div>
</div>
<!-- FOLD Format -->
<div class="section">
<h2>FOLD Format</h2>
<div class="schema-card">
<div class="schema-row">
<span class="schema-field">vertices_coords</span>
<span class="schema-type">[[x,y], ...]</span>
<span class="schema-desc">Vertex positions<span class="schema-tag required">required</span></span>
</div>
<div class="schema-row">
<span class="schema-field">edges_vertices</span>
<span class="schema-type">[[v1,v2], ...]</span>
<span class="schema-desc">Edge connectivity<span class="schema-tag required">required</span></span>
</div>
<div class="schema-row">
<span class="schema-field">edges_assignment</span>
<span class="schema-type">["B"|"M"|"V"|"F"|"U"]</span>
<span class="schema-desc">Edge types. Need &ge;1 M or V, &ge;1 B<span class="schema-tag required">required</span></span>
</div>
<div class="schema-row">
<span class="schema-field">edges_foldAngle</span>
<span class="schema-type">[degrees, ...]</span>
<span class="schema-desc">Fold angles. Defaults: V&rarr;180&deg;, M&rarr;&minus;180&deg;<span class="schema-tag optional">optional</span></span>
</div>
<div class="schema-row">
<span class="schema-field">faces_vertices</span>
<span class="schema-type">[[v0,v1,...], ...]</span>
<span class="schema-desc">Face polygons. Auto-computed if missing<span class="schema-tag optional">optional</span></span>
</div>
</div>
</div>
<!-- Observation -->
<div class="section">
<h2>Observation</h2>
<div class="schema-card">
<div class="schema-row">
<span class="schema-field">shape_similarity</span>
<span class="schema-type">float 0.0&ndash;1.0</span>
<span class="schema-desc">Procrustes match to target shape</span>
</div>
<div class="schema-row">
<span class="schema-field">final_positions</span>
<span class="schema-type">[[x,y,z], ...]</span>
<span class="schema-desc">Folded vertex positions</span>
</div>
<div class="schema-row">
<span class="schema-field">target_positions</span>
<span class="schema-type">[[x,y,z], ...]</span>
<span class="schema-desc">Expected target positions</span>
</div>
<div class="schema-row">
<span class="schema-field">max_strain</span>
<span class="schema-type">float</span>
<span class="schema-desc">Edge deformation metric</span>
</div>
<div class="schema-row">
<span class="schema-field">is_stable</span>
<span class="schema-type">bool</span>
<span class="schema-desc">Convergence flag</span>
</div>
<div class="schema-row">
<span class="schema-field">reward</span>
<span class="schema-type">float</span>
<span class="schema-desc">similarity &times; 20.0, or &minus;2.0 on error</span>
</div>
</div>
</div>
<!-- Rewards -->
<div class="section">
<h2>Rewards</h2>
<p style="font-size:14px;color:var(--text-secondary);font-weight:300;line-height:1.6;margin-bottom:20px">
The environment computes reward from the physics simulation result. Two reward functions are available for training:
</p>
<div class="schema-card">
<div class="schema-row">
<span class="schema-field">valid_fold</span>
<span class="schema-type">format reward</span>
<span class="schema-desc">+1.0 valid FOLD JSON, &minus;0.5 parseable but invalid structure, &minus;2.0 not parseable</span>
</div>
<div class="schema-row">
<span class="schema-field">shape_match</span>
<span class="schema-type">main reward</span>
<span class="schema-desc">similarity &times; 20.0 (0&ndash;20). &minus;1.0 if simulation fails, &minus;2.0 if invalid</span>
</div>
</div>
<h3 style="margin-top:24px">How shape_similarity is computed</h3>
<div class="schema-card">
<div class="schema-row">
<span class="schema-field">1. Simulate</span>
<span class="schema-desc">Run physics engine on submitted crease pattern &rarr; get final 3D vertex positions</span>
</div>
<div class="schema-row">
<span class="schema-field">2. Center</span>
<span class="schema-desc">Center both predicted and target point clouds at origin</span>
</div>
<div class="schema-row">
<span class="schema-field">3. Align</span>
<span class="schema-desc">Try 24 rotation alignments (90&deg; rotations + mirrors) to handle equivalent orientations</span>
</div>
<div class="schema-row">
<span class="schema-field">4. Chamfer</span>
<span class="schema-desc">Bidirectional nearest-neighbor distance, normalized by bounding box diagonal</span>
</div>
<div class="schema-row">
<span class="schema-field">5. Score</span>
<span class="schema-desc">similarity = 1 &minus; (chamfer / diagonal), clamped to [0, 1]. Reward = similarity &times; 20</span>
</div>
</div>
<p style="font-size:13px;color:var(--text-tertiary);font-weight:300;line-height:1.5;margin-top:12px">
<code>max_strain</code> measures edge length deviation after folding (0 = no deformation). <code>is_stable</code> indicates whether the simulation converged.
</p>
</div>
<!-- API Reference -->
<div class="section">
<h2>API Reference</h2>
<h3>WebSocket</h3>
<div class="api-group">
<div class="api-row">
<span class="api-method ws">WS</span>
<span class="api-path">/ws</span>
<span class="api-desc">Persistent connection</span>
</div>
</div>
<div class="ws-label">Send: Reset</div>
<div class="ws-block">{"type": "reset", "data": {"task_name": "triangle"}}</div>
<div class="ws-label">Send: Step</div>
<div class="ws-block">{"type": "step", "data": {"fold_data": {...}}}</div>
<div class="ws-label">Receive: Observation</div>
<div class="ws-block">{"type": "observation", "data": {"reward": 20.0, "done": true, ...}}</div>
<h3 style="margin-top:24px">REST</h3>
<div class="api-group">
<div class="api-row">
<span class="api-method">POST</span>
<span class="api-path">/sessions</span>
<span class="api-desc">Create session</span>
</div>
<div class="api-row">
<span class="api-method">POST</span>
<span class="api-path">/sessions/{id}/reset</span>
<span class="api-desc">Reset with task_name</span>
</div>
<div class="api-row">
<span class="api-method">POST</span>
<span class="api-path">/sessions/{id}/step</span>
<span class="api-desc">Submit fold action</span>
</div>
<div class="api-row">
<span class="api-method" style="background:#eaf2f8;color:var(--valley)">GET</span>
<span class="api-path">/tasks</span>
<span class="api-desc">List all tasks</span>
</div>
<div class="api-row">
<span class="api-method" style="background:#eaf2f8;color:var(--valley)">GET</span>
<span class="api-path">/tasks/{name}</span>
<span class="api-desc">Task detail + target fold</span>
</div>
</div>
</div>
<!-- Quick Start -->
<div class="section">
<h2>Quick Start</h2>
<div class="code-block">
<div class="code-header"><span>python</span><button class="copy-btn" onclick="copyCode(this)" title="Copy to clipboard"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
<pre><span class="kw">from</span> origami_env.client <span class="kw">import</span> OrigamiEnv
<span class="kw">from</span> origami_env.models <span class="kw">import</span> OrigamiAction
<span class="kw">with</span> <span class="fn">OrigamiEnv</span>(base_url=<span class="str">"http://localhost:8000"</span>) <span class="kw">as</span> env:
env.<span class="fn">reset</span>(task_name=<span class="str">"triangle"</span>)
result = env.<span class="fn">step</span>(<span class="fn">OrigamiAction</span>(fold_data={
<span class="str">"vertices_coords"</span>: [[<span class="num">0</span>,<span class="num">0</span>],[<span class="num">1</span>,<span class="num">0</span>],[<span class="num">1</span>,<span class="num">1</span>],[<span class="num">0</span>,<span class="num">1</span>]],
<span class="str">"edges_vertices"</span>: [[<span class="num">0</span>,<span class="num">1</span>],[<span class="num">1</span>,<span class="num">2</span>],[<span class="num">2</span>,<span class="num">3</span>],[<span class="num">3</span>,<span class="num">0</span>],[<span class="num">0</span>,<span class="num">2</span>]],
<span class="str">"edges_assignment"</span>: [<span class="str">"B"</span>,<span class="str">"B"</span>,<span class="str">"B"</span>,<span class="str">"B"</span>,<span class="str">"V"</span>],
<span class="str">"edges_foldAngle"</span>: [<span class="num">0</span>,<span class="num">0</span>,<span class="num">0</span>,<span class="num">0</span>,<span class="num">180</span>]
}))
<span class="fn">print</span>(result.observation.shape_similarity) <span class="cmt"># 1.0</span></pre>
</div>
<div class="code-block" style="margin-top:16px">
<div class="code-header"><span>python &mdash; all tasks</span><button class="copy-btn" onclick="copyCode(this)" title="Copy to clipboard"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
<pre><span class="kw">import</span> requests
<span class="cmt"># Fetch available tasks</span>
tasks = requests.<span class="fn">get</span>(<span class="str">"http://localhost:8000/tasks"</span>).<span class="fn">json</span>()
<span class="kw">for</span> name, info <span class="kw">in</span> tasks.<span class="fn">items</span>():
<span class="fn">print</span>(<span class="str">f"</span>{name}<span class="str">: difficulty </span>{info[<span class="str">'difficulty'</span>]}<span class="str">"</span>)
<span class="cmt"># Get target crease pattern for this task</span>
detail = requests.<span class="fn">get</span>(<span class="str">f"http://localhost:8000/tasks/</span>{name}<span class="str">"</span>).<span class="fn">json</span>()
target = detail[<span class="str">"target_fold"</span>]
<span class="cmt"># Create session, reset, step</span>
s = requests.<span class="fn">post</span>(<span class="str">"http://localhost:8000/sessions"</span>).<span class="fn">json</span>()
sid = s[<span class="str">"session_id"</span>]
requests.<span class="fn">post</span>(<span class="str">f"http://localhost:8000/sessions/</span>{sid}<span class="str">/reset"</span>, json={<span class="str">"task_name"</span>: name})
obs = requests.<span class="fn">post</span>(<span class="str">f"http://localhost:8000/sessions/</span>{sid}<span class="str">/step"</span>, json={<span class="str">"fold_data"</span>: target}).<span class="fn">json</span>()
<span class="fn">print</span>(<span class="str">f" reward: </span>{obs[<span class="str">'reward'</span>]}<span class="str">, similarity: </span>{obs[<span class="str">'shape_similarity'</span>]}<span class="str">"</span>)</pre>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
VIEW 2 β€” GRID
═══════════════════════════════════════════════════════════ -->
<div id="grid-view" class="view">
<div class="grid-content">
<div class="grid" id="grid"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
VIEW 3 β€” DETAIL
═══════════════════════════════════════════════════════════ -->
<div id="detail-view" class="view">
<div class="detail-header">
<button class="back-btn" id="back-btn">&larr; Back</button>
<div class="detail-title" id="detail-title"></div>
<div class="detail-subtitle" id="detail-subtitle"></div>
</div>
<div class="detail-body">
<div class="panel-2d">
<div class="panel-label">Crease Pattern</div>
<div class="crease-canvas" id="crease-2d"></div>
<div class="legend">
<div class="legend-item">
<div class="legend-swatch" style="background:var(--mountain)"></div>Mountain
</div>
<div class="legend-item">
<div class="legend-swatch" style="background:var(--valley)"></div>Valley
</div>
<div class="legend-item">
<div class="legend-swatch" style="background:var(--boundary)"></div>Boundary
</div>
</div>
</div>
<div class="panel-3d" id="panel-3d"></div>
</div>
<div class="slider-bar">
<div class="slider-label">flat</div>
<div class="slider-track">
<input type="range" id="crease-slider" min="0" max="100" value="100">
</div>
<div class="slider-value" id="slider-val">100%</div>
<div class="slider-label">folded</div>
</div>
<div class="metrics-bar">
<div class="metric">
<div class="metric-label">Similarity</div>
<div class="metric-value high" id="m-sim">&mdash;</div>
</div>
<div class="metric">
<div class="metric-label">Folds</div>
<div class="metric-value" id="m-folds">&mdash;</div>
</div>
<div class="metric">
<div class="metric-label">Strain</div>
<div class="metric-value" id="m-strain">&mdash;</div>
</div>
<div class="metric">
<div class="metric-label">Vertices</div>
<div class="metric-value" id="m-verts">&mdash;</div>
</div>
<div class="metric">
<div class="metric-label">Status</div>
<div class="metric-value" id="m-status">&mdash;</div>
</div>
</div>
</div>
<script>
function copyCode(btn) {
const pre = btn.closest('.code-block').querySelector('pre');
const text = pre.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.classList.add('copied');
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
}, 2000);
});
}
</script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.163.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.163.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ─── FOLD DATA ─────────────────────────────────────────────
const TASKS = {
triangle: {
name: 'Triangle',
description: 'Diagonal valley fold',
difficulty: 1,
similarity: 1.0,
fold: {
vertices_coords: [[0,0],[1,0],[1,1],[0,1]],
edges_vertices: [[0,1],[1,2],[2,3],[3,0],[0,2]],
edges_assignment: ['B','B','B','B','V'],
edges_foldAngle: [0,0,0,0,180],
faces_vertices: [[0,1,2],[0,2,3]],
},
strain: 0.0,
},
half_fold: {
name: 'Half Fold',
description: 'Horizontal valley fold',
difficulty: 1,
similarity: 1.0,
fold: {
vertices_coords: [[0,0],[1,0],[1,1],[0,1],[0,0.5],[1,0.5]],
edges_vertices: [[0,1],[1,5],[5,2],[2,3],[3,4],[4,0],[4,5]],
edges_assignment: ['B','B','B','B','B','B','V'],
edges_foldAngle: [0,0,0,0,0,0,180],
faces_vertices: [[0,1,5,4],[4,5,2,3]],
},
strain: 0.0,
},
quarter_fold: {
name: 'Quarter Fold',
description: 'Two perpendicular folds',
difficulty: 2,
similarity: 1.0,
fold: {
vertices_coords: [[0,0],[0.5,0],[1,0],[0,0.5],[0.5,0.5],[1,0.5],[0,1],[0.5,1],[1,1]],
edges_vertices: [[0,1],[1,2],[2,5],[5,8],[8,7],[7,6],[6,3],[3,0],[1,4],[4,7],[3,4],[4,5]],
edges_assignment: ['B','B','B','B','B','B','B','B','V','V','V','V'],
edges_foldAngle: [0,0,0,0,0,0,0,0,180,180,180,180],
faces_vertices: [[0,1,4,3],[1,2,5,4],[3,4,7,6],[4,5,8,7]],
},
strain: 0.0,
},
letter_fold: {
name: 'Letter Fold',
description: 'Two parallel folds',
difficulty: 2,
similarity: 1.0,
fold: {
vertices_coords: [[0,0],[1,0],[0,1/3],[1,1/3],[0,2/3],[1,2/3],[0,1],[1,1]],
edges_vertices: [[0,1],[1,3],[3,5],[5,7],[7,6],[6,4],[4,2],[2,0],[2,3],[4,5]],
edges_assignment: ['B','B','B','B','B','B','B','B','V','M'],
edges_foldAngle: [0,0,0,0,0,0,0,0,180,-180],
faces_vertices: [[0,1,3,2],[2,3,5,4],[4,5,7,6]],
},
strain: 0.0,
},
};
// ─── CLIENT-SIDE ANALYTICAL FOLD SOLVER ──────────────────────
// Cumulative transform approach: each face stores (R, t) where
// folded_pos = R * flat_pos + t. Correctly handles multiple folds.
function analyticalFold(fold, creasePercent) {
const verts = fold.vertices_coords;
const n = verts.length;
const flat = new Float32Array(n * 3);
const pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
flat[i*3] = verts[i][0]; flat[i*3+1] = verts[i][1]; flat[i*3+2] = 0;
pos[i*3] = verts[i][0]; pos[i*3+1] = verts[i][1]; pos[i*3+2] = 0;
}
if (Math.abs(creasePercent) < 1e-10) return pos;
const faces = fold.faces_vertices;
const edges = fold.edges_vertices;
const assignments = fold.edges_assignment;
const foldAngles = fold.edges_foldAngle;
if (!faces || faces.length === 0) return pos;
// Build face adjacency
const faceAdj = {};
for (let fi = 0; fi < faces.length; fi++) {
const face = faces[fi];
for (let j = 0; j < face.length; j++) {
const v1 = face[j], v2 = face[(j+1) % face.length];
const key = Math.min(v1,v2) + ',' + Math.max(v1,v2);
if (!faceAdj[key]) faceAdj[key] = [];
faceAdj[key].push(fi);
}
}
// Build crease map
const creaseMap = {};
for (let i = 0; i < edges.length; i++) {
const [v1, v2] = edges[i];
const key = Math.min(v1,v2) + ',' + Math.max(v1,v2);
if (assignments[i] === 'M' || assignments[i] === 'V') {
creaseMap[key] = (foldAngles[i] * Math.PI / 180) * creasePercent;
}
}
// Per-face cumulative transform: folded = R * flat + t
const faceR = new Array(faces.length).fill(null);
const faceT = new Array(faces.length).fill(null);
faceR[0] = [1,0,0, 0,1,0, 0,0,1];
faceT[0] = [0,0,0];
const visited = new Array(faces.length).fill(false);
visited[0] = true;
const placed = new Set();
for (const vi of faces[0]) placed.add(vi);
const queue = [0];
while (queue.length > 0) {
const fi = queue.shift();
const face = faces[fi];
for (let j = 0; j < face.length; j++) {
const v1 = face[j], v2 = face[(j+1) % face.length];
const key = Math.min(v1,v2) + ',' + Math.max(v1,v2);
for (const fj of (faceAdj[key] || [])) {
if (visited[fj]) continue;
visited[fj] = true;
queue.push(fj);
const angle = creaseMap[key] || 0;
let Rfj, tfj;
if (Math.abs(angle) > 1e-10) {
const p1 = [pos[v1*3], pos[v1*3+1], pos[v1*3+2]];
const ax = [pos[v2*3]-p1[0], pos[v2*3+1]-p1[1], pos[v2*3+2]-p1[2]];
const axLen = Math.sqrt(ax[0]*ax[0]+ax[1]*ax[1]+ax[2]*ax[2]);
if (axLen < 1e-12) {
Rfj = faceR[fi].slice(); tfj = faceT[fi].slice();
} else {
const u = [ax[0]/axLen, ax[1]/axLen, ax[2]/axLen];
const foldR = rodriguesMat(u, angle);
Rfj = matMul(foldR, faceR[fi]);
const dt = [faceT[fi][0]-p1[0], faceT[fi][1]-p1[1], faceT[fi][2]-p1[2]];
const rdt = matVec(foldR, dt);
tfj = [rdt[0]+p1[0], rdt[1]+p1[1], rdt[2]+p1[2]];
}
} else {
Rfj = faceR[fi].slice();
tfj = faceT[fi].slice();
}
faceR[fj] = Rfj;
faceT[fj] = tfj;
for (const vi of faces[fj]) {
if (!placed.has(vi)) {
const fv = [flat[vi*3], flat[vi*3+1], flat[vi*3+2]];
const rv = matVec(Rfj, fv);
pos[vi*3] = rv[0] + tfj[0];
pos[vi*3+1] = rv[1] + tfj[1];
pos[vi*3+2] = rv[2] + tfj[2];
placed.add(vi);
}
}
}
}
}
return pos;
}
function rodriguesMat(u, angle) {
const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
return [
c + u[0]*u[0]*t, u[0]*u[1]*t - u[2]*s, u[0]*u[2]*t + u[1]*s,
u[1]*u[0]*t + u[2]*s, c + u[1]*u[1]*t, u[1]*u[2]*t - u[0]*s,
u[2]*u[0]*t - u[1]*s, u[2]*u[1]*t + u[0]*s, c + u[2]*u[2]*t,
];
}
function matMul(a, b) {
return [
a[0]*b[0]+a[1]*b[3]+a[2]*b[6], a[0]*b[1]+a[1]*b[4]+a[2]*b[7], a[0]*b[2]+a[1]*b[5]+a[2]*b[8],
a[3]*b[0]+a[4]*b[3]+a[5]*b[6], a[3]*b[1]+a[4]*b[4]+a[5]*b[7], a[3]*b[2]+a[4]*b[5]+a[5]*b[8],
a[6]*b[0]+a[7]*b[3]+a[8]*b[6], a[6]*b[1]+a[7]*b[4]+a[8]*b[7], a[6]*b[2]+a[7]*b[5]+a[8]*b[8],
];
}
function matVec(m, v) {
return [
m[0]*v[0]+m[1]*v[1]+m[2]*v[2],
m[3]*v[0]+m[4]*v[1]+m[5]*v[2],
m[6]*v[0]+m[7]*v[1]+m[8]*v[2],
];
}
// ─── THREE.JS HELPERS ─────────────────────────────────────────
const EDGE_COLORS = { M: 0xc0392b, V: 0x2471a3, B: 0x1a1816, F: 0xddd8d0, U: 0xa9a49d };
function buildMesh(task, creasePercent = 1) {
const fold = task.fold;
const group = new THREE.Group();
const positions = analyticalFold(fold, creasePercent);
const faces = fold.faces_vertices;
const indices = [];
for (const face of faces) {
for (let i = 1; i < face.length - 1; i++) {
indices.push(face[0], face[i], face[i+1]);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const mat = new THREE.MeshPhongMaterial({
color: 0xfaf9f7,
side: THREE.DoubleSide,
flatShading: false,
shininess: 20,
specular: 0x222222,
});
const mesh = new THREE.Mesh(geo, mat);
group.add(mesh);
for (let i = 0; i < fold.edges_vertices.length; i++) {
const [v1, v2] = fold.edges_vertices[i];
const assignment = fold.edges_assignment[i];
const color = EDGE_COLORS[assignment] || 0xaaaaaa;
const lineGeo = new THREE.BufferGeometry();
const linePos = new Float32Array([
positions[v1*3], positions[v1*3+1], positions[v1*3+2],
positions[v2*3], positions[v2*3+1], positions[v2*3+2],
]);
lineGeo.setAttribute('position', new THREE.BufferAttribute(linePos, 3));
const lineMat = new THREE.LineBasicMaterial({
color,
linewidth: assignment === 'B' ? 2 : 1,
});
group.add(new THREE.LineSegments(lineGeo, lineMat));
}
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
group.position.sub(center);
return group;
}
function setupScene(container, opts = {}) {
const w = container.clientWidth || 300;
const h = container.clientHeight || 200;
const scene = new THREE.Scene();
scene.background = opts.bg ? new THREE.Color(opts.bg) : new THREE.Color(0xfaf9f7);
const camera = new THREE.PerspectiveCamera(35, w / h, 0.01, 100);
camera.position.set(0, 0.4, 2.2);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.7);
dirLight1.position.set(1, 2, 3);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-2, 1, -1);
scene.add(dirLight2);
let controls = null;
if (opts.orbit) {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 0.8;
controls.maxDistance = 5;
controls.autoRotate = opts.autoRotate || false;
controls.autoRotateSpeed = 1.5;
}
return { scene, camera, renderer, controls };
}
// ─── VIEW NAVIGATION ──────────────────────────────────────────
let gridInitialized = false;
const cardScenes = [];
function showView(name) {
// Hide all views
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
// Update header
const headerTitle = document.getElementById('header-title');
const headerTag = document.getElementById('header-tag');
const headerNav = document.getElementById('header-nav');
if (name === 'landing') {
document.getElementById('landing-view').classList.add('active');
headerTitle.textContent = 'Origami RL';
headerTag.textContent = 'environment';
headerTag.style.display = '';
headerNav.textContent = 'Origamis \u2192';
headerNav.onclick = () => showView('grid');
headerNav.style.display = '';
document.getElementById('main-header').style.display = '';
} else if (name === 'grid') {
document.getElementById('grid-view').classList.add('active');
headerTitle.textContent = 'Origamis';
headerTag.style.display = 'none';
headerNav.textContent = '\u2190 Back';
headerNav.onclick = () => showView('landing');
headerNav.style.display = '';
document.getElementById('main-header').style.display = '';
if (!gridInitialized) initGrid();
} else if (name === 'detail') {
document.getElementById('detail-view').classList.add('active');
document.getElementById('main-header').style.display = 'none';
}
}
// Expose globally for inline onclick
window.showView = showView;
// ─── GRID VIEW ────────────────────────────────────────────────
function initGrid() {
gridInitialized = true;
const gridEl = document.getElementById('grid');
for (const [key, task] of Object.entries(TASKS)) {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="card-canvas" id="card-${key}"></div>
<div class="card-info">
<div class="card-name">${task.name}</div>
<div class="card-desc">${task.description} \u00b7 difficulty ${task.difficulty}</div>
<div class="card-score">
<div class="score-bar-bg"><div class="score-bar-fill" style="width:${task.similarity * 100}%"></div></div>
<div class="score-label">${Math.round(task.similarity * 100)}%</div>
</div>
</div>
`;
card.addEventListener('click', () => showDetail(key));
gridEl.appendChild(card);
}
// Init mini 3D scenes after DOM paint
requestAnimationFrame(() => {
for (const [key, task] of Object.entries(TASKS)) {
const container = document.getElementById(`card-${key}`);
if (!container) continue;
const { scene, camera, renderer } = setupScene(container, { autoRotate: true });
const mesh = buildMesh(task, 1.0);
scene.add(mesh);
const animate = () => {
requestAnimationFrame(animate);
mesh.rotation.y += 0.005;
renderer.render(scene, camera);
};
animate();
cardScenes.push({ key, scene, camera, renderer, mesh });
}
});
}
// ─── DETAIL VIEW ──────────────────────────────────────────────
let detailScene = null;
let detailRenderer = null;
let detailCamera = null;
let detailControls = null;
let detailMeshGroup = null;
let detailAnimId = null;
let currentTaskKey = null;
function showDetail(key) {
currentTaskKey = key;
const task = TASKS[key];
showView('detail');
document.getElementById('detail-title').textContent = task.name;
document.getElementById('detail-subtitle').textContent = task.description;
// 2D crease pattern
draw2DCrease(task);
// Metrics
const foldCount = task.fold.edges_assignment.filter(a => a === 'M' || a === 'V').length;
document.getElementById('m-sim').textContent = Math.round(task.similarity * 100) + '%';
document.getElementById('m-folds').textContent = foldCount;
document.getElementById('m-strain').textContent = task.strain.toFixed(4);
document.getElementById('m-verts').textContent = task.fold.vertices_coords.length;
document.getElementById('m-status').textContent = task.strain < 0.05 ? 'stable' : 'settling';
const simEl = document.getElementById('m-sim');
simEl.className = 'metric-value ' + (task.similarity > 0.9 ? 'high' : task.similarity > 0.5 ? 'mid' : 'low');
// 3D viewer
const panel = document.getElementById('panel-3d');
if (detailRenderer) {
panel.removeChild(detailRenderer.domElement);
detailRenderer.dispose();
if (detailAnimId) cancelAnimationFrame(detailAnimId);
}
const setup = setupScene(panel, { orbit: true, bg: 0xf5f3f0 });
detailScene = setup.scene;
detailCamera = setup.camera;
detailRenderer = setup.renderer;
detailControls = setup.controls;
const slider = document.getElementById('crease-slider');
slider.value = 100;
document.getElementById('slider-val').textContent = '100%';
updateDetailMesh(1.0);
addTargetGhost(task);
const animateDetail = () => {
detailAnimId = requestAnimationFrame(animateDetail);
detailControls.update();
detailRenderer.render(detailScene, detailCamera);
};
animateDetail();
const resizeObserver = new ResizeObserver(() => {
const w = panel.clientWidth;
const h = panel.clientHeight;
detailCamera.aspect = w / h;
detailCamera.updateProjectionMatrix();
detailRenderer.setSize(w, h);
});
resizeObserver.observe(panel);
}
function updateDetailMesh(cp) {
if (!currentTaskKey) return;
const task = TASKS[currentTaskKey];
if (detailMeshGroup) {
detailScene.remove(detailMeshGroup);
}
detailMeshGroup = buildMesh(task, cp);
detailScene.add(detailMeshGroup);
}
function addTargetGhost(task) {
const fold = task.fold;
const positions = analyticalFold(fold, 1.0);
const faces = fold.faces_vertices;
const indices = [];
for (const face of faces) {
for (let i = 1; i < face.length - 1; i++) {
indices.push(face[0], face[i], face[i+1]);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setIndex(indices);
const ghostMat = new THREE.MeshBasicMaterial({
color: 0xc0392b,
wireframe: true,
transparent: true,
opacity: 0.15,
});
const ghost = new THREE.Mesh(geo, ghostMat);
const box = new THREE.Box3().setFromBufferAttribute(geo.getAttribute('position'));
const center = box.getCenter(new THREE.Vector3());
ghost.position.sub(center);
detailScene.add(ghost);
}
function draw2DCrease(task) {
const container = document.getElementById('crease-2d');
const fold = task.fold;
const verts = fold.vertices_coords;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const v of verts) {
minX = Math.min(minX, v[0]); minY = Math.min(minY, v[1]);
maxX = Math.max(maxX, v[0]); maxY = Math.max(maxY, v[1]);
}
const pad = 0.15;
const vw = maxX - minX + pad * 2;
const vh = maxY - minY + pad * 2;
const size = 280;
const scale = size / Math.max(vw, vh);
const toX = (x) => (x - minX + pad) * scale;
const toY = (y) => size - (y - minY + pad) * scale;
const STROKE = { M: '#c0392b', V: '#2471a3', B: '#1a1816', F: '#ddd8d0', U: '#a9a49d' };
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`;
for (const face of fold.faces_vertices) {
const pts = face.map(vi => `${toX(verts[vi][0])},${toY(verts[vi][1])}`).join(' ');
svg += `<polygon points="${pts}" fill="#ffffff" stroke="none"/>`;
}
for (let i = 0; i < fold.edges_vertices.length; i++) {
const [v1, v2] = fold.edges_vertices[i];
const a = fold.edges_assignment[i];
const color = STROKE[a] || '#aaa';
const width = a === 'B' ? 2 : 1.5;
const dash = a === 'M' ? 'stroke-dasharray="6 3"' : a === 'V' ? 'stroke-dasharray="2 2"' : '';
svg += `<line x1="${toX(verts[v1][0])}" y1="${toY(verts[v1][1])}" x2="${toX(verts[v2][0])}" y2="${toY(verts[v2][1])}" stroke="${color}" stroke-width="${width}" stroke-linecap="round" ${dash}/>`;
}
for (let i = 0; i < verts.length; i++) {
svg += `<circle cx="${toX(verts[i][0])}" cy="${toY(verts[i][1])}" r="3" fill="var(--text)" opacity="0.3"/>`;
}
svg += '</svg>';
container.innerHTML = svg;
}
function showGrid() {
showView('grid');
}
// ─── SLIDER ───────────────────────────────────────────────────
document.getElementById('crease-slider').addEventListener('input', (e) => {
const val = parseInt(e.target.value);
document.getElementById('slider-val').textContent = val + '%';
updateDetailMesh(val / 100);
});
// ─── BACK BUTTON & KEYBOARD ───────────────────────────────────
document.getElementById('back-btn').addEventListener('click', () => showView('grid'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (currentTaskKey) {
showView('grid');
currentTaskKey = null;
if (detailAnimId) cancelAnimationFrame(detailAnimId);
} else if (document.getElementById('grid-view').classList.contains('active')) {
showView('landing');
}
}
});
// ─── WEBSOCKET (optional live connection) ─────────────────────
function connectWS() {
try {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => console.log('[WS] Connected');
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'observation' && data.data?.observation) {
const obs = data.data.observation;
console.log('[WS] Observation:', obs.shape_similarity);
}
};
ws.onerror = () => console.log('[WS] No server (using demo data)');
} catch (e) {
// Server not running, use demo data
}
}
connectWS();
// ─── START ────────────────────────────────────────────────────
showView('landing');
</script>
</body>
</html>