optigami / research /origami /origami_simulator_code.md
sissississi's picture
go-back (#6)
e9b7141

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

  1. Repository Structure
  2. FOLD Format Parsing β€” How the Simulator Loads Files
  3. Mesh & Geometry Representation
  4. Triangulation β€” How Faces Are Split
  5. The Simulation Model β€” GPU-Accelerated Bar-and-Hinge
  6. Strain Computation & Visualization
  7. Real FOLD File Examples
  8. 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:

  1. Triangles are always planar (3 points define a plane).
  2. Non-triangular faces need "facet hinges" to penalize bending β€” but you need triangles to define a dihedral angle.
  3. 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 pattern
  • square-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)

  1. Load FOLD file -> parse vertices_coords, edges_vertices, edges_assignment, faces_vertices
  2. Triangulate non-triangular faces via ear-clipping -> creates new internal edges
  3. Classify edges -> fold creases (M/V with target angles), facet hinges (flat target, high stiffness), boundary (no constraint)
  4. Build GPU textures -> pack node positions, beam params, crease params into RGBA float textures
  5. 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
  6. Compute strain -> per-edge engineering strain = |L - L0| / L0, averaged per face
  7. Render -> Three.js mesh colored by strain (blue=zero, red=max)
  8. 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.