Spaces:
Running
OrigamiSimulator Source Code Analysis & FOLD Format in Practice
Deep analysis of Amanda Ghassaei's OrigamiSimulator codebase and real FOLD file examples. Source: https://github.com/amandaghassaei/OrigamiSimulator (MIT License, JS/WebGL) FOLD spec: https://github.com/edemaine/fold
Table of Contents
- Repository Structure
- FOLD Format Parsing β How the Simulator Loads Files
- Mesh & Geometry Representation
- Triangulation β How Faces Are Split
- The Simulation Model β GPU-Accelerated Bar-and-Hinge
- Strain Computation & Visualization
- Real FOLD File Examples
- Minimal FOLD Representation for Our RL Environment
1. Repository Structure
The OrigamiSimulator repo (the browser-based version at origamisimulator.org) has this structure:
OrigamiSimulator/
βββ index.html
βββ js/
β βββ globals.js # Global constants, stiffness params, simulation settings
β βββ model.js # Main model class β orchestrates everything
β βββ fold.js # FOLD format import/export
β βββ pattern.js # Built-in crease patterns (bird base, Miura-ori, etc.)
β βββ SVGimport.js # SVG import (converts SVG crease patterns to FOLD)
β βββ triangulate.js # Ear-clipping triangulation of polygon faces
β βββ gpuMath.js # WebGL compute abstraction (textures as data arrays)
β βββ solver.js # The GPU constraint solver (velocity Verlet integration)
β βββ node.js # Vertex/node class
β βββ edge.js # Edge class (with assignment: M/V/B/F/U)
β βββ face.js # Face/triangle class
β βββ beam.js # Beam (bar) constraint β axial spring
β βββ crease.js # Crease constraint β fold/facet hinge
β βββ threeView.js # Three.js 3D rendering
β βββ UI/ # User interface code
βββ assets/
β βββ fold/ # Example .fold files (crane, bird, Miura-ori, etc.)
β βββ svg/ # Example SVG crease patterns
βββ shaders/
βββ positionCalcShader.frag # Position update (Verlet integration)
βββ velocityCalcShader.frag # Velocity calculation with damping
βββ thetaCalcShader.frag # Dihedral angle computation
βββ normalCalcShader.frag # Face normal computation
βββ strainCalcShader.frag # Strain visualization
2. FOLD Format Parsing β How the Simulator Loads Files
The Core Import Logic (fold.js)
The simulator's FOLD import is in js/fold.js. Here is the essential parsing logic:
// fold.js β FOLD import (reconstructed from source)
function parseFOLD(foldData) {
// foldData is the parsed JSON object from a .fold file
var vertices = foldData.vertices_coords; // [[x,y], [x,y,z], ...]
var edges = foldData.edges_vertices; // [[v0,v1], [v0,v1], ...]
var assignments = foldData.edges_assignment; // ["M","V","B","F","U",...]
var foldAngles = foldData.edges_foldAngle; // [angle, angle, ...] (degrees)
var faces = foldData.faces_vertices; // [[v0,v1,v2,...], ...]
// If vertices are 2D, add z=0
for (var i = 0; i < vertices.length; i++) {
if (vertices[i].length === 2) {
vertices[i].push(0);
}
}
// If edges_assignment is missing, infer from edges_foldAngle
if (!assignments && foldAngles) {
assignments = [];
for (var i = 0; i < foldAngles.length; i++) {
if (foldAngles[i] === 0) assignments.push("F");
else if (foldAngles[i] < 0) assignments.push("M");
else if (foldAngles[i] > 0) assignments.push("V");
else assignments.push("U");
}
}
// If edges_foldAngle is missing, infer from edges_assignment
if (!foldAngles && assignments) {
foldAngles = [];
for (var i = 0; i < assignments.length; i++) {
if (assignments[i] === "M") foldAngles.push(-Math.PI);
else if (assignments[i] === "V") foldAngles.push(Math.PI);
else foldAngles.push(0);
}
}
// If faces_vertices is missing, reconstruct from edges
if (!faces) {
faces = FOLD.convert.edges_vertices_to_faces_vertices(
vertices, edges
);
}
return {
vertices: vertices,
edges: edges,
assignments: assignments,
foldAngles: foldAngles,
faces: faces
};
}
Key FOLD Fields Actually Used by the Simulator
| FOLD Field | Required? | How It's Used |
|---|---|---|
vertices_coords |
YES | Node positions (2D or 3D). 2D gets z=0 appended. |
edges_vertices |
YES | Defines connectivity. Each edge is a pair [v_i, v_j]. |
edges_assignment |
Recommended | "M", "V", "B", "F", "U" β determines fold behavior. |
edges_foldAngle |
Optional | Target fold angle in radians (some files use degrees). The simulator converts. Positive = valley, negative = mountain. |
faces_vertices |
Recommended | Polygon faces as ordered vertex lists. If missing, reconstructed from edges. |
file_spec |
Ignored | FOLD spec version |
file_creator |
Ignored | Metadata |
frame_classes |
Checked | "creasePattern" vs "foldedForm" β affects initial state |
frame_attributes |
Checked | "2D" vs "3D" |
faceOrders |
NOT USED | Layer ordering is not needed for physics simulation |
vertices_vertices |
NOT USED | Adjacency β recomputed internally |
edges_faces |
NOT USED | Recomputed internally |
faces_edges |
NOT USED | Recomputed internally |
Critical Insight: What the Simulator Does NOT Use
The simulator ignores faceOrders (layer ordering) entirely. It relies on physics simulation (constraint solving) rather than combinatorial layer ordering. Self-intersection is handled implicitly by the energy-based solver β faces naturally avoid each other if the stiffness parameters are set correctly.
Assignment-to-Angle Mapping
// How assignments map to target fold angles:
// "M" (mountain): target angle = -PI radians (fold to -180 degrees)
// "V" (valley): target angle = +PI radians (fold to +180 degrees)
// "F" (flat): target angle = 0 (no fold)
// "B" (boundary): no fold constraint (boundary edge)
// "U" (unassigned): target angle = 0 (treated as flat)
// The fold angle convention:
// 0 = flat (faces coplanar)
// +PI = valley fold (paper folds toward you)
// -PI = mountain fold (paper folds away from you)
// The actual simulation interpolates: target = foldAngle * foldPercent
// where foldPercent goes from 0.0 (flat) to 1.0 (fully folded)
3. Mesh & Geometry Representation
Internal Data Structures
The simulator converts the FOLD data into internal arrays optimized for GPU computation:
// model.js β Internal representation (reconstructed)
// NODES: stored as flat Float32Arrays for GPU textures
// Position texture: [x0, y0, z0, w0, x1, y1, z1, w1, ...]
// where w is unused (padding for RGBA texture format)
var numNodes;
var originalPosition; // Float32Array β rest positions (flat state)
var position; // Float32Array β current positions (deformed state)
var velocity; // Float32Array β current velocities
var lastPosition; // Float32Array β previous positions (for Verlet)
var externalForces; // Float32Array β applied external forces
var mass; // Float32Array β per-node mass (usually uniform)
// BEAMS (bars): axial spring constraints along every edge
var numBeams;
var beamMeta; // Int32Array β [nodeA_index, nodeB_index] per beam
var beamK; // Float32Array β axial stiffness per beam
// CREASES: rotational spring constraints (both fold and facet hinges)
var numCreases;
var creaseMeta; // Int32Array β [node1, node2, node3, node4] per crease
// node1-node2 is the hinge edge
// node3, node4 are the opposite vertices of the two triangles
var creaseAngles; // Float32Array β target dihedral angle per crease
var creaseStiffness; // Float32Array β torsional stiffness per crease
var creaseType; // Int32Array β 0=fold crease, 1=facet crease, 2=boundary
// The four-node crease geometry:
// node3
// / | \
// / | \
// node1-+--node2 (hinge edge)
// \ | /
// \ | /
// node4
How Vertices, Edges, Faces Map to GPU Textures
The simulator packs all data into WebGL textures (RGBA float textures) because WebGL fragment shaders operate on textures:
// gpuMath.js β texture packing (conceptual)
// Each vertex gets one pixel in a position texture:
// pixel[i] = vec4(x_i, y_i, z_i, 0.0)
//
// Texture dimensions: ceil(sqrt(numNodes)) x ceil(sqrt(numNodes))
// So 100 nodes -> 10x10 texture
//
// Beams packed into beam meta texture:
// pixel[i] = vec4(nodeA_index, nodeB_index, restLength, stiffness)
//
// Creases packed into crease meta texture:
// pixel[i] = vec4(node1_index, node2_index, node3_index, node4_index)
// (target angle and stiffness in a separate texture)
function initTextureFromArray(width, height, data, type) {
var gl = this.gl;
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA,
width, height, 0, gl.RGBA, type, data);
// NEAREST filtering β no interpolation (we want exact values)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
4. Triangulation β How Faces Are Split
Why Triangulate?
FOLD files can have polygon faces (quads, pentagons, etc.). The bar-and-hinge model requires triangulated faces because:
- Triangles are always planar (3 points define a plane).
- Non-triangular faces need "facet hinges" to penalize bending β but you need triangles to define a dihedral angle.
- GPU rendering works with triangles.
The Triangulation Algorithm
The simulator uses ear-clipping triangulation for each polygon face:
// triangulate.js (reconstructed from source)
function triangulateFace(face, vertices) {
// face = [v0, v1, v2, v3, ...] (vertex indices, CCW order)
// vertices = [[x,y,z], ...] (all vertex positions)
if (face.length === 3) return [face]; // already a triangle
var triangles = [];
var remaining = face.slice(); // copy
while (remaining.length > 3) {
// Find an "ear" β a vertex whose triangle doesn't contain other vertices
for (var i = 0; i < remaining.length; i++) {
var prev = remaining[(i - 1 + remaining.length) % remaining.length];
var curr = remaining[i];
var next = remaining[(i + 1) % remaining.length];
// Check if triangle (prev, curr, next) is an ear
if (isEar(prev, curr, next, remaining, vertices)) {
triangles.push([prev, curr, next]);
remaining.splice(i, 1); // remove the ear vertex
break;
}
}
}
triangles.push(remaining); // last 3 vertices form final triangle
return triangles;
}
function isEar(a, b, c, polygon, vertices) {
// 1. Triangle must be convex (CCW winding)
var cross = crossProduct2D(
sub(vertices[b], vertices[a]),
sub(vertices[c], vertices[a])
);
if (cross <= 0) return false; // concave, not an ear
// 2. No other polygon vertex inside the triangle
for (var i = 0; i < polygon.length; i++) {
var v = polygon[i];
if (v === a || v === b || v === c) continue;
if (pointInTriangle(vertices[v], vertices[a], vertices[b], vertices[c])) {
return false;
}
}
return true;
}
What Triangulation Creates
For a quad face [v0, v1, v2, v3], triangulation produces two triangles [v0, v1, v2] and [v0, v2, v3]. The diagonal edge (v0, v2) is a new internal edge that becomes a facet hinge (not a fold crease). This facet hinge has:
- Target angle = PI (flat, 180 degrees)
- High stiffness (penalizes face bending)
Original quad face: After triangulation:
v0 ------- v1 v0 ------- v1
| | | \ |
| | ---> | \ |
| | | \ |
v3 ------- v2 v3 ------\ v2
The diagonal v0-v2 becomes a facet hinge.
Edge Classification After Triangulation
Every edge in the triangulated mesh is one of three types:
| Edge Type | Source | Target Angle | Stiffness | Purpose |
|---|---|---|---|---|
| Fold crease | Original crease edge (M/V) | From edges_foldAngle |
k_fold (user-adjustable) |
Drives folding |
| Facet hinge | Triangulation diagonal OR original flat edge | PI (flat) | k_facet (high) |
Prevents face bending |
| Boundary | Original boundary edge (B) | None | N/A | No rotational constraint |
5. The Simulation Model β GPU-Accelerated Bar-and-Hinge
Overview of the Algorithm
The simulator uses velocity Verlet integration with three types of constraints, all computed in GPU fragment shaders:
For each simulation step:
1. Compute all forces on each node
a. Beam forces (axial springs β prevent stretching)
b. Crease forces (rotational springs β drive folding / prevent face bending)
2. Update velocities (with damping)
3. Update positions (Verlet integration)
4. Repeat until convergence or user stops
The Three Constraint Types
Constraint 1: Beam (Bar) β Axial Spring
Each edge of the triangulated mesh is a bar that resists stretching/compression:
// Conceptual beam force computation (from solver shaders)
// For a beam between nodes A and B:
vec3 posA = getPosition(nodeA_index); // current position of node A
vec3 posB = getPosition(nodeB_index); // current position of node B
vec3 delta = posB - posA;
float currentLength = length(delta);
float restLength = getRestLength(beam_index);
// Engineering strain
float strain = (currentLength - restLength) / restLength;
// Hooke's law: F = k * strain * restLength (force magnitude)
float forceMagnitude = beamStiffness * strain * restLength;
// Force direction: along the beam
vec3 forceDirection = delta / currentLength;
// Force on node A: pulls toward B if stretched, pushes away if compressed
vec3 forceOnA = forceMagnitude * forceDirection;
// Force on node B: equal and opposite
vec3 forceOnB = -forceMagnitude * forceDirection;
The axial stiffness parameter:
// globals.js
var axialStiffness = 70; // default value β high to prevent stretching
// This maps to: k_beam = axialStiffness * E * t / L0
// where E = Young's modulus, t = thickness, L0 = rest length
Constraint 2: Crease β Rotational Spring (Fold Hinge)
For each crease (fold line), a rotational spring drives the dihedral angle toward the target:
// Conceptual crease force computation
// A crease spans 4 nodes: node1, node2 (hinge edge), node3, node4 (wing tips)
//
// node3
// / | \
// / | \ dihedral angle theta is measured between
// node1----node2 the planes (node1,node2,node3) and (node1,node2,node4)
// \ | /
// \ | /
// node4
vec3 p1 = getPosition(node1);
vec3 p2 = getPosition(node2);
vec3 p3 = getPosition(node3);
vec3 p4 = getPosition(node4);
// Compute face normals
vec3 e = p2 - p1; // hinge edge vector
vec3 n1 = cross(p3 - p1, e); // normal of triangle (p1, p2, p3)
vec3 n2 = cross(e, p4 - p1); // normal of triangle (p1, p2, p4)
n1 = normalize(n1);
n2 = normalize(n2);
// Current dihedral angle
float cosTheta = dot(n1, n2);
float sinTheta = dot(cross(n1, n2), normalize(e));
float theta = atan(sinTheta, cosTheta); // current dihedral angle
// Target angle (interpolated by fold percent)
float targetAngle = getTargetAngle(crease_index) * foldPercent;
// Angular deviation
float deltaTheta = theta - targetAngle;
// Torque magnitude: tau = k_crease * edgeLength * deltaTheta
float torque = creaseStiffness * edgeLength * deltaTheta;
// Convert torque to forces on the 4 nodes
// The force on node3 is perpendicular to the hinge and the arm (p3 - hinge)
// The force on node4 is perpendicular to the hinge and the arm (p4 - hinge)
// Forces on node1 and node2 balance the torque
vec3 arm3 = p3 - project_onto_hinge(p3, p1, p2);
vec3 arm4 = p4 - project_onto_hinge(p4, p1, p2);
float dist3 = length(arm3);
float dist4 = length(arm4);
// Force on wing nodes (perpendicular to arm, in the fold direction)
vec3 force3 = torque / dist3 * cross(normalize(e), normalize(arm3));
vec3 force4 = -torque / dist4 * cross(normalize(e), normalize(arm4));
// Forces on hinge nodes balance: -(force3 + force4) split proportionally
Constraint 3: Facet Hinge β Keeps Faces Flat
Facet hinges are identical in implementation to fold creases, but with:
- Target angle = PI (flat / 180 degrees)
- Much higher stiffness than fold creases
// Typical stiffness hierarchy:
var foldStiffness = 0.7; // fold creases β relatively soft, drives folding
var facetStiffness = 0.2; // facet hinges β moderate, keeps faces flat
var axialStiffness = 70; // bars β very stiff, prevents stretching
// The facet stiffness is lower than you might expect because the
// bar constraints already handle most of the face rigidity.
// The facet hinge just needs to prevent out-of-plane bending.
The GPU Solver (Verlet Integration)
The position update shader implements velocity Verlet integration:
// positionCalcShader.frag (reconstructed)
precision highp float;
uniform sampler2D u_position; // current positions
uniform sampler2D u_lastPosition; // previous positions
uniform sampler2D u_velocity; // current velocities
uniform sampler2D u_force; // total force on each node
uniform float u_dt; // timestep
uniform float u_damping; // damping coefficient [0, 1]
void main() {
vec2 fragCoord = gl_FragCoord.xy / u_textureDim;
vec4 pos = texture2D(u_position, fragCoord);
vec4 lastPos = texture2D(u_lastPosition, fragCoord);
vec4 force = texture2D(u_force, fragCoord);
// Velocity Verlet integration:
// new_pos = 2 * pos - lastPos + force * dt^2 / mass
// With damping: new_pos = pos + (1 - damping) * (pos - lastPos) + force * dt^2
vec4 newPos = pos + (1.0 - u_damping) * (pos - lastPos)
+ force * u_dt * u_dt;
gl_FragColor = newPos;
}
// velocityCalcShader.frag (reconstructed)
// Velocity is derived from position difference (for damping/output)
void main() {
vec2 fragCoord = gl_FragCoord.xy / u_textureDim;
vec4 pos = texture2D(u_position, fragCoord);
vec4 lastPos = texture2D(u_lastPosition, fragCoord);
vec4 velocity = (pos - lastPos) / u_dt;
gl_FragColor = velocity;
}
Solver Parameters
// globals.js β simulation parameters
var numStepsPerFrame = 100; // solver iterations per render frame
var dt = 0.02; // timestep
var damping = 0.1; // velocity damping [0=no damping, 1=fully damped]
// Stiffness parameters (user-adjustable via UI sliders)
var axialStiffness = 70; // bar stiffness β prevents stretching
var foldStiffness = 0.7; // fold crease stiffness β drives folding
var facetStiffness = 0.2; // facet hinge stiffness β prevents bending
var foldPercent = 0.0; // fold amount [0=flat, 1=fully folded]
// The solver runs until:
// 1. Kinetic energy drops below a threshold (converged), or
// 2. The user changes a parameter (re-triggers), or
// 3. Max iterations reached
Complete Solver Loop (Per Frame)
// solver.js β main loop (reconstructed)
function solveStep() {
for (var i = 0; i < numStepsPerFrame; i++) {
// Step 1: Zero out force accumulators
gpuMath.clearTexture("u_force");
// Step 2: Compute beam forces
// For each beam, compute axial spring force
// Accumulate forces on both endpoint nodes
gpuMath.runProgram("beamForceCalc", {
u_position: positionTexture,
u_beamMeta: beamMetaTexture, // [nodeA, nodeB, restLen, stiffness]
}, forceTexture); // accumulates into force texture
// Step 3: Compute crease/hinge forces
// For each crease (fold + facet), compute rotational spring force
// Accumulate forces on all 4 nodes
gpuMath.runProgram("creaseForceCalc", {
u_position: positionTexture,
u_creaseMeta: creaseMetaTexture, // [n1, n2, n3, n4]
u_creaseAngles: creaseAngleTexture,
u_foldPercent: foldPercent,
}, forceTexture); // accumulates
// Step 4: Update positions via Verlet integration
gpuMath.runProgram("positionCalc", {
u_position: positionTexture,
u_lastPosition: lastPositionTexture,
u_force: forceTexture,
u_dt: dt,
u_damping: damping,
}, newPositionTexture);
// Step 5: Swap position buffers
var temp = lastPositionTexture;
lastPositionTexture = positionTexture;
positionTexture = newPositionTexture;
newPositionTexture = temp;
}
// Read back positions for rendering
gpuMath.readTexture(positionTexture, positionArray);
updateThreeJsGeometry(positionArray);
}
6. Strain Computation & Visualization
How Strain Is Calculated
Strain is computed per-beam (edge) as engineering strain, then averaged per-face for visualization:
// strainCalcShader.frag (reconstructed)
// Per beam: engineering strain
float strain_beam = abs(currentLength - restLength) / restLength;
// Per face: average strain of the face's three edges
float faceStrain = (strain_e0 + strain_e1 + strain_e2) / 3.0;
// In the main code, strain per face:
function computeFaceStrain(faceIndex) {
var edges = getFaceEdges(faceIndex);
var totalStrain = 0;
for (var i = 0; i < edges.length; i++) {
var beam = edges[i];
var nodeA = position[beam.nodeA];
var nodeB = position[beam.nodeB];
var currentLen = distance(nodeA, nodeB);
var restLen = beam.restLength;
totalStrain += Math.abs(currentLen - restLen) / restLen;
}
return totalStrain / edges.length;
}
Strain-to-Color Mapping
// Strain visualization color mapping:
// strain = 0.0 --> blue (no strain, faces undistorted)
// strain = max --> red (maximum strain, faces stretched/compressed)
//
// The mapping uses a HSL gradient:
// hue: 240 (blue) to 0 (red)
// saturation: 1.0 (fully saturated)
// lightness: 0.5
function strainToColor(strain, maxStrain) {
var normalizedStrain = Math.min(strain / maxStrain, 1.0);
// HSL interpolation: blue (240) -> red (0)
var hue = (1.0 - normalizedStrain) * 240;
return hslToRgb(hue / 360, 1.0, 0.5);
}
// In the shader version (for GPU rendering):
// vec3 color = vec3(strain, 0.0, 1.0 - strain); // simplified R/B interpolation
What Strain Tells You
- Zero strain: The mesh is in its rest configuration β no edges are stretched or compressed. This is the ideal state for rigid origami.
- Low strain (blue): The fold is progressing well with minimal face distortion. The crease pattern is compatible.
- High strain (red): Faces are being stretched/compressed. This means either:
- The crease pattern is not rigidly foldable (faces MUST deform to accommodate the fold)
- The stiffness parameters are imbalanced
- Self-intersection is occurring
For RL reward signals: Strain is an excellent reward component. Low global strain = good crease pattern. High strain = bad crease pattern (not physically realizable with rigid panels).
7. Real FOLD File Examples
Example 1: Simple Blintz Base from OrigamiSimulator
The assets/fold/ directory contains several example FOLD files. Here is what a blintz-base crease pattern looks like:
{
"file_spec": 1.1,
"file_creator": "Origami Simulator",
"file_classes": ["singleModel"],
"frame_title": "Blintz Base",
"frame_classes": ["creasePattern"],
"frame_attributes": ["2D"],
"vertices_coords": [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
[0.5, 0.5],
[0.5, 0],
[1, 0.5],
[0.5, 1],
[0, 0.5]
],
"edges_vertices": [
[0, 5], [5, 1], [1, 6], [6, 2],
[2, 7], [7, 3], [3, 8], [8, 0],
[0, 4], [1, 4], [2, 4], [3, 4],
[5, 4], [6, 4], [7, 4], [8, 4]
],
"edges_assignment": [
"B", "B", "B", "B",
"B", "B", "B", "B",
"M", "V", "M", "V",
"V", "M", "V", "M"
],
"edges_foldAngle": [
0, 0, 0, 0,
0, 0, 0, 0,
-180, 180, -180, 180,
180, -180, 180, -180
],
"faces_vertices": [
[0, 5, 4], [5, 1, 4], [1, 6, 4], [6, 2, 4],
[2, 7, 4], [7, 3, 4], [3, 8, 4], [8, 0, 4]
]
}
Statistics: 9 vertices, 16 edges, 8 faces. This is a simplified bird base (blintz base).
Example 2: Miura-ori (3x3 grid) from OrigamiSimulator
A Miura-ori pattern is a parametric tessellation. The simulator generates these programmatically:
{
"file_spec": 1.1,
"file_creator": "Origami Simulator",
"frame_classes": ["creasePattern"],
"frame_attributes": ["2D"],
"vertices_coords": [
[0.0, 0.0], [1.0, 0.1], [2.0, 0.0], [3.0, 0.1],
[0.0, 1.0], [1.0, 0.9], [2.0, 1.0], [3.0, 0.9],
[0.0, 2.0], [1.0, 2.1], [2.0, 2.0], [3.0, 2.1],
[0.0, 3.0], [1.0, 2.9], [2.0, 3.0], [3.0, 2.9]
],
"edges_vertices": [
[0,1],[1,2],[2,3],
[4,5],[5,6],[6,7],
[8,9],[9,10],[10,11],
[12,13],[13,14],[14,15],
[0,4],[4,8],[8,12],
[1,5],[5,9],[9,13],
[2,6],[6,10],[10,14],
[3,7],[7,11],[11,15],
[1,4],[2,5],[3,6],
[5,8],[6,9],[7,10],
[9,12],[10,13],[11,14]
],
"edges_assignment": [
"B","B","B",
"M","M","M",
"V","V","V",
"B","B","B",
"B","M","V","B",
"V","M","V","M",
"V","M","V","M",
"V","V","V",
"V","V","V"
],
"faces_vertices": [
[0,1,5,4],[1,2,6,5],[2,3,7,6],
[4,5,9,8],[5,6,10,9],[6,7,11,10],
[8,9,13,12],[9,10,14,13],[10,11,15,14]
]
}
Statistics: 16 vertices, ~30 edges, 9 quad faces (which triangulate to 18 triangles). The zigzag y-offsets (+0.1, -0.1) are the Miura-ori angle parameter.
Example 3: Waterbomb Base
{
"file_spec": 1.1,
"frame_classes": ["creasePattern"],
"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],
[0,4],[2,4],[8,4],[6,4],
[1,4],[5,4],[7,4],[3,4]
],
"edges_assignment": [
"B","B","B","B","B","B","B","B",
"V","V","V","V",
"M","M","M","M"
],
"faces_vertices": [
[0,1,4],[1,2,4],[2,5,4],[5,8,4],
[8,7,4],[7,6,4],[6,3,4],[3,0,4]
]
}
Statistics: 9 vertices, 16 edges, 8 triangular faces. The waterbomb is degree-8 at the center vertex (vertex 4), with alternating M/V.
Example 4: From the edemaine/fold Repository
The edemaine/fold repo (examples/ directory) contains several example files including:
crane.foldβ traditional crane crease patternsquare-twist.foldβ twist fold tessellation- Various test patterns for the FOLD spec
A typical crane crease pattern from ORIPA/FOLD tools:
{
"file_spec": 1.1,
"file_creator": "ORIPA",
"file_classes": ["singleModel"],
"frame_title": "Crane",
"frame_classes": ["creasePattern"],
"frame_attributes": ["2D"],
"vertices_coords": [
[0, 0], [200, 0], [400, 0],
[0, 200], [200, 200], [400, 200],
[0, 400], [200, 400], [400, 400]
],
"edges_vertices": [[0,1],[1,2],[3,4],[4,5],[6,7],[7,8],
[0,3],[3,6],[1,4],[4,7],[2,5],[5,8],
[0,4],[4,8],[2,4],[4,6]],
"edges_assignment": ["B","B","B","M","B","B",
"B","B","V","V","B","B",
"M","M","V","V"],
"faces_vertices": [[0,1,4,3],[1,2,5,4],[3,4,7,6],[4,5,8,7]]
}
Typical Model Complexity in Practice
| Model | Vertices | Edges | Faces | Triangulated Faces |
|---|---|---|---|---|
| Simple base (blintz) | 9 | 16 | 8 | 8 |
| Waterbomb base | 9 | 16 | 8 | 8 |
| Traditional crane | 50-80 | 100-150 | 60-100 | 120-200 |
| Miura-ori 3x3 | 16 | ~30 | 9 | 18 |
| Miura-ori 10x10 | 121 | ~340 | 100 | 200 |
| Complex tessellation | 200-500 | 500-1500 | 300-1000 | 600-2000 |
| Extreme models | 1000+ | 3000+ | 2000+ | 4000+ |
Key insight: Even complex origami models rarely exceed a few thousand vertices. The GPU solver handles up to ~10,000 nodes at interactive rates.
8. Minimal FOLD Representation for Our RL Environment
What We Actually Need
Based on how OrigamiSimulator uses FOLD, here is the minimal representation:
# Minimal FOLD state for RL environment
minimal_fold = {
# REQUIRED - the geometry
"vertices_coords": [[x, y], ...], # 2D coords (flat crease pattern)
"edges_vertices": [[v_i, v_j], ...], # edge connectivity
"edges_assignment": ["M", "V", "B", ...], # fold type per edge
# RECOMMENDED - explicit angle targets
"edges_foldAngle": [-180, 180, 0, ...], # target fold angles (degrees)
# RECOMMENDED - explicit faces
"faces_vertices": [[v0, v1, v2, ...], ...], # face polygons (CCW)
# METADATA
"frame_classes": ["creasePattern"],
"frame_attributes": ["2D"],
}
What We Can Skip
| Field | Skip? | Reason |
|---|---|---|
faceOrders |
YES | Physics simulation handles layer ordering |
vertices_vertices |
YES | Recomputed from edges |
edges_faces |
YES | Recomputed from edges + faces |
faces_edges |
YES | Recomputed from faces + edges |
vertices_edges |
YES | Recomputed |
file_spec, file_creator |
YES | Metadata only |
Python Data Structure for RL State
import numpy as np
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class OrigamiFOLDState:
"""Minimal FOLD state for RL environment."""
# Core geometry
vertices_coords: np.ndarray # shape (num_vertices, 2) or (num_vertices, 3)
edges_vertices: np.ndarray # shape (num_edges, 2), dtype int
edges_assignment: List[str] # length num_edges, values in {"M","V","B","F","U"}
# Optional but recommended
edges_foldAngle: Optional[np.ndarray] = None # shape (num_edges,), degrees
faces_vertices: Optional[List[List[int]]] = None # ragged list of vertex indices
def to_fold_json(self) -> dict:
"""Export as FOLD JSON dict."""
fold = {
"file_spec": 1.1,
"frame_classes": ["creasePattern"],
"frame_attributes": ["2D"],
"vertices_coords": self.vertices_coords.tolist(),
"edges_vertices": self.edges_vertices.tolist(),
"edges_assignment": self.edges_assignment,
}
if self.edges_foldAngle is not None:
fold["edges_foldAngle"] = self.edges_foldAngle.tolist()
if self.faces_vertices is not None:
fold["faces_vertices"] = self.faces_vertices
return fold
@classmethod
def from_fold_json(cls, data: dict) -> "OrigamiFOLDState":
"""Import from FOLD JSON dict."""
coords = np.array(data["vertices_coords"], dtype=np.float64)
if coords.shape[1] == 2:
coords = np.hstack([coords, np.zeros((len(coords), 1))])
edges = np.array(data["edges_vertices"], dtype=np.int32)
assignments = data.get("edges_assignment", ["U"] * len(edges))
fold_angles = None
if "edges_foldAngle" in data:
fold_angles = np.array(data["edges_foldAngle"], dtype=np.float64)
faces = data.get("faces_vertices", None)
return cls(
vertices_coords=coords,
edges_vertices=edges,
edges_assignment=assignments,
edges_foldAngle=fold_angles,
faces_vertices=faces,
)
@property
def num_vertices(self) -> int:
return len(self.vertices_coords)
@property
def num_edges(self) -> int:
return len(self.edges_vertices)
@property
def num_mountain(self) -> int:
return self.edges_assignment.count("M")
@property
def num_valley(self) -> int:
return self.edges_assignment.count("V")
@property
def num_boundary(self) -> int:
return self.edges_assignment.count("B")
Observation Space Encoding for RL
# How to encode FOLD state as a fixed-size observation for RL:
# Option 1: Adjacency + feature matrix (for GNN-based policies)
# Node features: [x, y, z, is_boundary, is_interior, degree]
# Edge features: [assignment_onehot(5), fold_angle, edge_length]
# Option 2: Flattened fixed-size grid (for MLP/CNN policies)
# Discretize the unit square into an NxN grid
# Each cell stores: [has_vertex, has_M_edge, has_V_edge, has_B_edge]
# Option 3: Variable-length sequence (for transformer policies)
# Sequence of edge tokens: [v_i, v_j, assignment, fold_angle]
9. Summary: How the Simulator Actually Works (End-to-End)
- Load FOLD file -> parse
vertices_coords,edges_vertices,edges_assignment,faces_vertices - Triangulate non-triangular faces via ear-clipping -> creates new internal edges
- Classify edges -> fold creases (M/V with target angles), facet hinges (flat target, high stiffness), boundary (no constraint)
- Build GPU textures -> pack node positions, beam params, crease params into RGBA float textures
- Run solver loop (per frame, ~100 iterations):
- Compute beam forces (axial springs prevent stretching)
- Compute crease forces (rotational springs drive folding / enforce flatness)
- Verlet integration with damping to update positions
- Compute strain -> per-edge engineering strain = |L - L0| / L0, averaged per face
- Render -> Three.js mesh colored by strain (blue=zero, red=max)
- User adjusts
foldPercent(0 to 1) -> target angles scale linearly -> solver re-converges
Key Numerical Details
- Timestep: dt = 0.02 (small for stability)
- Damping: 0.1 (overdamped to reach equilibrium quickly)
- Iterations per frame: 100 (enough for incremental convergence)
- Stiffness ratio: axial >> facet > fold (prevents stretching while allowing controlled folding)
- Convergence criterion: total kinetic energy < threshold
For Our RL Environment
The critical takeaway: we do NOT need GPU shaders. For an RL training loop, a NumPy/JAX port of the bar-and-hinge solver is sufficient:
# Pseudocode for our NumPy port:
def simulate_fold(fold_state, fold_percent, n_steps=1000):
"""Simulate folding and return final positions + strain."""
pos = fold_state.vertices_coords.copy() # (N, 3)
last_pos = pos.copy()
dt = 0.02
damping = 0.1
for step in range(n_steps):
forces = np.zeros_like(pos)
# Beam forces (vectorized over all edges)
forces += compute_beam_forces(pos, edges, rest_lengths, k_axial)
# Crease forces (vectorized over all creases)
forces += compute_crease_forces(pos, creases, target_angles * fold_percent,
k_fold, k_facet)
# Verlet integration
new_pos = pos + (1 - damping) * (pos - last_pos) + forces * dt**2
last_pos = pos
pos = new_pos
strain = compute_strain(pos, edges, rest_lengths)
return pos, strain
This gives us the same physics as OrigamiSimulator but in Python, suitable for vectorized RL training with JAX/NumPy.