ianalin123 commited on
Commit
3744ef3
·
1 Parent(s): dc79e2a

Add 3D fold preview modes

Browse files
Files changed (3) hide show
  1. src/App.css +41 -0
  2. src/App.js +30 -0
  3. src/components/Fold3DCanvas.js +327 -0
src/App.css CHANGED
@@ -122,6 +122,7 @@
122
  padding: 16px;
123
  flex-shrink: 0;
124
  border-bottom: 1px solid var(--border);
 
125
  }
126
 
127
  .canvas-wrap {
@@ -129,6 +130,7 @@
129
  flex-direction: column;
130
  gap: 8px;
131
  flex: 1;
 
132
  }
133
 
134
  .canvas-wrap + .canvas-wrap {
@@ -149,6 +151,45 @@
149
  background: var(--paper-white);
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  /* ─── STEP FEED ─── */
153
  .step-feed-section {
154
  flex: 1;
 
122
  padding: 16px;
123
  flex-shrink: 0;
124
  border-bottom: 1px solid var(--border);
125
+ overflow-x: auto;
126
  }
127
 
128
  .canvas-wrap {
 
130
  flex-direction: column;
131
  gap: 8px;
132
  flex: 1;
133
+ min-width: 280px;
134
  }
135
 
136
  .canvas-wrap + .canvas-wrap {
 
151
  background: var(--paper-white);
152
  }
153
 
154
+ .canvas-3d {
155
+ display: block;
156
+ background: linear-gradient(180deg, #1a1a2e 0%, #0f101a 100%);
157
+ border: 1px solid var(--border);
158
+ }
159
+
160
+ .canvas-label-row {
161
+ display: flex;
162
+ align-items: center;
163
+ justify-content: space-between;
164
+ gap: 10px;
165
+ }
166
+
167
+ .fold-mode-toggle {
168
+ display: inline-flex;
169
+ border: 1px solid var(--border);
170
+ background: var(--surface);
171
+ }
172
+
173
+ .fold-mode-btn {
174
+ border: none;
175
+ background: transparent;
176
+ color: var(--text-dim);
177
+ font-family: var(--font-display);
178
+ font-size: 9px;
179
+ letter-spacing: 0.08em;
180
+ padding: 3px 7px;
181
+ cursor: pointer;
182
+ }
183
+
184
+ .fold-mode-btn + .fold-mode-btn {
185
+ border-left: 1px solid var(--border);
186
+ }
187
+
188
+ .fold-mode-btn.active {
189
+ color: var(--text-primary);
190
+ background: #1f2538;
191
+ }
192
+
193
  /* ─── STEP FEED ─── */
194
  .step-feed-section {
195
  flex: 1;
src/App.js CHANGED
@@ -6,6 +6,7 @@ import StepFeed from './components/StepFeed';
6
  import InfoBadges from './components/InfoBadges';
7
  import TargetSelector from './components/TargetSelector';
8
  import PlayerControls from './components/PlayerControls';
 
9
 
10
  const API_BASE = 'http://localhost:8000';
11
 
@@ -15,6 +16,7 @@ function App() {
15
  const [episode, setEpisode] = useState(null);
16
  const [currentStep, setCurrentStep] = useState(0);
17
  const [playing, setPlaying] = useState(false);
 
18
  const [apiStatus, setApiStatus] = useState('connecting'); // 'connecting' | 'ok' | 'err'
19
  const [episodeLoading, setEpisodeLoading] = useState(false);
20
  const intervalRef = useRef(null);
@@ -158,6 +160,34 @@ function App() {
158
  ghostOnly={false}
159
  />
160
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  </div>
162
 
163
  <div className="step-feed-section">
 
6
  import InfoBadges from './components/InfoBadges';
7
  import TargetSelector from './components/TargetSelector';
8
  import PlayerControls from './components/PlayerControls';
9
+ import Fold3DCanvas from './components/Fold3DCanvas';
10
 
11
  const API_BASE = 'http://localhost:8000';
12
 
 
16
  const [episode, setEpisode] = useState(null);
17
  const [currentStep, setCurrentStep] = useState(0);
18
  const [playing, setPlaying] = useState(false);
19
+ const [foldRenderMode, setFoldRenderMode] = useState('progressive'); // 'progressive' | 'final'
20
  const [apiStatus, setApiStatus] = useState('connecting'); // 'connecting' | 'ok' | 'err'
21
  const [episodeLoading, setEpisodeLoading] = useState(false);
22
  const intervalRef = useRef(null);
 
160
  ghostOnly={false}
161
  />
162
  </div>
163
+ <div className="canvas-wrap">
164
+ <div className="canvas-label-row">
165
+ <span className="canvas-label">3D FOLD PREVIEW</span>
166
+ <div className="fold-mode-toggle">
167
+ <button
168
+ className={`fold-mode-btn${foldRenderMode === 'progressive' ? ' active' : ''}`}
169
+ onClick={() => setFoldRenderMode('progressive')}
170
+ type="button"
171
+ >
172
+ PER CREASE
173
+ </button>
174
+ <button
175
+ className={`fold-mode-btn${foldRenderMode === 'final' ? ' active' : ''}`}
176
+ onClick={() => setFoldRenderMode('final')}
177
+ type="button"
178
+ >
179
+ FOLD AT END
180
+ </button>
181
+ </div>
182
+ </div>
183
+ <Fold3DCanvas
184
+ steps={episode ? episode.steps : []}
185
+ currentStep={currentStep}
186
+ totalSteps={totalSteps}
187
+ mode={foldRenderMode}
188
+ dim={280}
189
+ />
190
+ </div>
191
  </div>
192
 
193
  <div className="step-feed-section">
src/components/Fold3DCanvas.js ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
+
3
+ const PAPER_RGB = [250, 250, 245];
4
+ const LIGHT_DIR = normalize3([0.4, -0.45, 1.0]);
5
+ const MAX_FOLD_RAD = Math.PI * 0.92;
6
+ const SIDE_EPS = 1e-7;
7
+ const MOUNTAIN_COLOR = 'rgba(245, 158, 11, 0.95)';
8
+ const VALLEY_COLOR = 'rgba(56, 189, 248, 0.95)';
9
+
10
+ function clamp(value, min, max) {
11
+ return Math.min(Math.max(value, min), max);
12
+ }
13
+
14
+ function normalize3(v) {
15
+ const mag = Math.hypot(v[0], v[1], v[2]);
16
+ if (mag < 1e-12) return [0, 0, 0];
17
+ return [v[0] / mag, v[1] / mag, v[2] / mag];
18
+ }
19
+
20
+ function cross3(a, b) {
21
+ return [
22
+ a[1] * b[2] - a[2] * b[1],
23
+ a[2] * b[0] - a[0] * b[2],
24
+ a[0] * b[1] - a[1] * b[0],
25
+ ];
26
+ }
27
+
28
+ function sub3(a, b) {
29
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
30
+ }
31
+
32
+ function dot3(a, b) {
33
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
34
+ }
35
+
36
+ function shadePaper(intensity) {
37
+ const lit = clamp(0.3 + 0.7 * Math.abs(intensity), 0.0, 1.0);
38
+ const r = Math.round(PAPER_RGB[0] * lit);
39
+ const g = Math.round(PAPER_RGB[1] * lit);
40
+ const b = Math.round(PAPER_RGB[2] * lit);
41
+ return `rgb(${r}, ${g}, ${b})`;
42
+ }
43
+
44
+ function buildGridMesh(resolution = 18) {
45
+ const vertices = [];
46
+ for (let y = 0; y <= resolution; y += 1) {
47
+ for (let x = 0; x <= resolution; x += 1) {
48
+ vertices.push([x / resolution, y / resolution, 0]);
49
+ }
50
+ }
51
+
52
+ const triangles = [];
53
+ const stride = resolution + 1;
54
+ for (let y = 0; y < resolution; y += 1) {
55
+ for (let x = 0; x < resolution; x += 1) {
56
+ const a = y * stride + x;
57
+ const b = a + 1;
58
+ const c = a + stride;
59
+ const d = c + 1;
60
+ triangles.push([a, b, d]);
61
+ triangles.push([a, d, c]);
62
+ }
63
+ }
64
+
65
+ return { vertices, triangles, resolution };
66
+ }
67
+
68
+ function rotateAroundAxis(point, axisPoint, axisDir, angleRad) {
69
+ const px = point[0] - axisPoint[0];
70
+ const py = point[1] - axisPoint[1];
71
+ const pz = point[2] - axisPoint[2];
72
+
73
+ const kx = axisDir[0];
74
+ const ky = axisDir[1];
75
+ const kz = axisDir[2];
76
+
77
+ const cosA = Math.cos(angleRad);
78
+ const sinA = Math.sin(angleRad);
79
+
80
+ const crossX = ky * pz - kz * py;
81
+ const crossY = kz * px - kx * pz;
82
+ const crossZ = kx * py - ky * px;
83
+
84
+ const dot = px * kx + py * ky + pz * kz;
85
+ const oneMinus = 1.0 - cosA;
86
+
87
+ return [
88
+ axisPoint[0] + px * cosA + crossX * sinA + kx * dot * oneMinus,
89
+ axisPoint[1] + py * cosA + crossY * sinA + ky * dot * oneMinus,
90
+ axisPoint[2] + pz * cosA + crossZ * sinA + kz * dot * oneMinus,
91
+ ];
92
+ }
93
+
94
+ function applyFoldToVertices(vertices, fold, progress) {
95
+ if (!fold || progress <= 0) return;
96
+ const [x1, y1] = fold.from;
97
+ const [x2, y2] = fold.to;
98
+ const dx = x2 - x1;
99
+ const dy = y2 - y1;
100
+ const len = Math.hypot(dx, dy);
101
+ if (len < 1e-8) return;
102
+
103
+ const sideValues = [];
104
+ let posCount = 0;
105
+ let negCount = 0;
106
+
107
+ for (let i = 0; i < vertices.length; i += 1) {
108
+ const v = vertices[i];
109
+ const side = dx * (v[1] - y1) - dy * (v[0] - x1);
110
+ sideValues.push(side);
111
+ if (side > SIDE_EPS) posCount += 1;
112
+ else if (side < -SIDE_EPS) negCount += 1;
113
+ }
114
+
115
+ let rotatePositive = posCount <= negCount;
116
+ if (posCount === 0 && negCount > 0) rotatePositive = false;
117
+ if (negCount === 0 && posCount > 0) rotatePositive = true;
118
+ if (posCount === 0 && negCount === 0) return;
119
+
120
+ const sign = fold.assignment === 'V' ? 1 : -1;
121
+ const angle = sign * MAX_FOLD_RAD * progress;
122
+ const axisPoint = [x1, y1, 0];
123
+ const axisDir = [dx / len, dy / len, 0];
124
+
125
+ for (let i = 0; i < vertices.length; i += 1) {
126
+ const side = sideValues[i];
127
+ const shouldRotate = rotatePositive ? side > SIDE_EPS : side < -SIDE_EPS;
128
+ if (!shouldRotate) continue;
129
+ vertices[i] = rotateAroundAxis(vertices[i], axisPoint, axisDir, angle);
130
+ }
131
+ }
132
+
133
+ function projectVertex(vertex, dim) {
134
+ let x = vertex[0] - 0.5;
135
+ let y = vertex[1] - 0.5;
136
+ let z = vertex[2];
137
+
138
+ const pitch = 1.04;
139
+ const yaw = -0.78;
140
+
141
+ const cp = Math.cos(pitch);
142
+ const sp = Math.sin(pitch);
143
+ const y1 = y * cp - z * sp;
144
+ const z1 = y * sp + z * cp;
145
+
146
+ const cy = Math.cos(yaw);
147
+ const sy = Math.sin(yaw);
148
+ const x2 = x * cy + z1 * sy;
149
+ const z2 = -x * sy + z1 * cy;
150
+
151
+ const camDist = 2.8;
152
+ const perspective = camDist / (camDist - z2);
153
+
154
+ return {
155
+ x: dim * 0.5 + x2 * perspective * dim * 0.82,
156
+ y: dim * 0.52 - y1 * perspective * dim * 0.82,
157
+ z: z2,
158
+ };
159
+ }
160
+
161
+ function foldProgresses(stepValue, foldCount, mode, totalSteps) {
162
+ const values = new Array(foldCount).fill(0);
163
+ if (foldCount === 0) return values;
164
+
165
+ if (mode === 'final') {
166
+ const startCollapse = Math.max(totalSteps - 1, 0);
167
+ const collapse = clamp(stepValue - startCollapse, 0, 1);
168
+ for (let i = 0; i < foldCount; i += 1) values[i] = collapse;
169
+ return values;
170
+ }
171
+
172
+ for (let i = 0; i < foldCount; i += 1) {
173
+ if (stepValue >= i + 1) values[i] = 1;
174
+ else if (stepValue > i) values[i] = clamp(stepValue - i, 0, 1);
175
+ }
176
+ return values;
177
+ }
178
+
179
+ function stepEasing(t) {
180
+ return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
181
+ }
182
+
183
+ export default function Fold3DCanvas({
184
+ steps,
185
+ currentStep,
186
+ totalSteps,
187
+ mode = 'progressive',
188
+ dim = 280,
189
+ }) {
190
+ const canvasRef = useRef(null);
191
+ const rafRef = useRef(null);
192
+ const animatedStepRef = useRef(currentStep);
193
+
194
+ const folds = useMemo(
195
+ () => (steps || [])
196
+ .map((s) => s.fold)
197
+ .filter(Boolean)
198
+ .map((fold) => ({
199
+ from: [Number(fold.from_point[0]), Number(fold.from_point[1])],
200
+ to: [Number(fold.to_point[0]), Number(fold.to_point[1])],
201
+ assignment: fold.assignment === 'M' ? 'M' : 'V',
202
+ })),
203
+ [steps],
204
+ );
205
+
206
+ const mesh = useMemo(() => buildGridMesh(18), []);
207
+
208
+ const draw = useCallback((stepValue) => {
209
+ const canvas = canvasRef.current;
210
+ if (!canvas) return;
211
+ const ctx = canvas.getContext('2d');
212
+ if (!ctx) return;
213
+
214
+ ctx.clearRect(0, 0, dim, dim);
215
+ ctx.fillStyle = '#121220';
216
+ ctx.fillRect(0, 0, dim, dim);
217
+
218
+ const vertices = mesh.vertices.map((v) => [v[0], v[1], v[2]]);
219
+ const progress = foldProgresses(stepValue, folds.length, mode, totalSteps);
220
+
221
+ for (let i = 0; i < folds.length; i += 1) {
222
+ if (progress[i] <= 0) continue;
223
+ applyFoldToVertices(vertices, folds[i], progress[i]);
224
+ }
225
+
226
+ const projected = vertices.map((v) => projectVertex(v, dim));
227
+
228
+ const tris = mesh.triangles.map((tri) => {
229
+ const p0 = projected[tri[0]];
230
+ const p1 = projected[tri[1]];
231
+ const p2 = projected[tri[2]];
232
+ const avgZ = (p0.z + p1.z + p2.z) / 3;
233
+
234
+ const v0 = vertices[tri[0]];
235
+ const v1 = vertices[tri[1]];
236
+ const v2 = vertices[tri[2]];
237
+ const normal = normalize3(cross3(sub3(v1, v0), sub3(v2, v0)));
238
+ const intensity = dot3(normal, LIGHT_DIR);
239
+
240
+ return {
241
+ tri,
242
+ avgZ,
243
+ shade: shadePaper(intensity),
244
+ };
245
+ });
246
+
247
+ tris.sort((a, b) => a.avgZ - b.avgZ);
248
+
249
+ for (const triInfo of tris) {
250
+ const [a, b, c] = triInfo.tri;
251
+ const p0 = projected[a];
252
+ const p1 = projected[b];
253
+ const p2 = projected[c];
254
+
255
+ ctx.beginPath();
256
+ ctx.moveTo(p0.x, p0.y);
257
+ ctx.lineTo(p1.x, p1.y);
258
+ ctx.lineTo(p2.x, p2.y);
259
+ ctx.closePath();
260
+ ctx.fillStyle = triInfo.shade;
261
+ ctx.fill();
262
+ ctx.strokeStyle = 'rgba(42, 42, 58, 0.22)';
263
+ ctx.lineWidth = 0.55;
264
+ ctx.stroke();
265
+ }
266
+
267
+ const res = mesh.resolution;
268
+ const stride = res + 1;
269
+ const pointToIndex = (pt) => {
270
+ const ix = clamp(Math.round(pt[0] * res), 0, res);
271
+ const iy = clamp(Math.round(pt[1] * res), 0, res);
272
+ return iy * stride + ix;
273
+ };
274
+
275
+ for (let i = 0; i < folds.length; i += 1) {
276
+ if (progress[i] <= 0.02) continue;
277
+ const fold = folds[i];
278
+ const aIdx = pointToIndex(fold.from);
279
+ const bIdx = pointToIndex(fold.to);
280
+ const pa = projected[aIdx];
281
+ const pb = projected[bIdx];
282
+
283
+ ctx.beginPath();
284
+ ctx.moveTo(pa.x, pa.y);
285
+ ctx.lineTo(pb.x, pb.y);
286
+ ctx.strokeStyle = fold.assignment === 'M' ? MOUNTAIN_COLOR : VALLEY_COLOR;
287
+ ctx.globalAlpha = clamp(0.35 + 0.65 * progress[i], 0, 1);
288
+ ctx.lineWidth = 2.15;
289
+ ctx.stroke();
290
+ ctx.globalAlpha = 1;
291
+ }
292
+ }, [dim, folds, mesh, mode, totalSteps]);
293
+
294
+ useEffect(() => {
295
+ draw(animatedStepRef.current);
296
+ }, [draw]);
297
+
298
+ useEffect(() => {
299
+ cancelAnimationFrame(rafRef.current);
300
+ const startValue = animatedStepRef.current;
301
+ const endValue = currentStep;
302
+ const durationMs = 420;
303
+ const startAt = performance.now();
304
+
305
+ const tick = (now) => {
306
+ const t = clamp((now - startAt) / durationMs, 0, 1);
307
+ const eased = stepEasing(t);
308
+ const value = startValue + (endValue - startValue) * eased;
309
+ animatedStepRef.current = value;
310
+ draw(value);
311
+ if (t < 1) rafRef.current = requestAnimationFrame(tick);
312
+ };
313
+
314
+ rafRef.current = requestAnimationFrame(tick);
315
+ return () => cancelAnimationFrame(rafRef.current);
316
+ }, [currentStep, draw]);
317
+
318
+ return (
319
+ <canvas
320
+ ref={canvasRef}
321
+ width={dim}
322
+ height={dim}
323
+ className="canvas-3d"
324
+ aria-label="3D fold preview"
325
+ />
326
+ );
327
+ }