Skip to documentation
CranberryClock.Ideas / Systems / Design

INDEPENDENT TOOL / v0.1.0

LoomTutorial

Work through the Loom example and adapt the pattern to your own project.

Tutorial: a moving object on a ribbon

javascript
import * as THREE from "three";
import { Loom, createLoomSample } from "@cranberry-forge/loom";

const track = new Loom({
  points: [
    [-8, 0, 0],
    [-3, 1, -3],
    [3, 0.5, 3],
    [8, 0, 0],
  ],
  width: 1.4,
  segments: 160,
  bank: 0,
  borderWidth: 0.08,
  borderHeight: 0.12,
});
scene.add(track);

// The host owns its train mesh, clock, render loop, and travel policy.
const frame = createLoomSample();
let distance = 0;
function update(dt) {
  distance = Math.min(track.length, distance + dt * 2);
  track.sampleDistance(distance, frame);
  train.position.copy(frame.position).addScaledVector(frame.up, 0.1);
  train.quaternion.copy(frame.quaternion);
}

scene and train are host Three objects. This example assumes the train and track have the same parent and the track has an identity transform. The frame quaternion maps a model's local +X to right, +Y to up, and +Z to forward. Rotate an imported model inside a parent group when it uses a different forward axis.

Open routes clamp at their ends. Closed routes wrap fractions and distances, including negative values. The demonstration implements its own shuttle reversal on open routes; Loom does not own movement or elapsed time.

Variable width and bank

Profiles use normalized arc distance, not control-point index or the raw Catmull–Rom parameter. Keys interpolate linearly. Bank is radians, using a right-handed rotation about the forward tangent.

javascript
track.configure({
  width: [
    { at: 0, value: 1.4 },
    { at: 0.5, value: 2.2 },
    { at: 1, value: 1.4 },
  ],
  bank: [
    { at: 0, value: 0 },
    { at: 0.3, value: 0.2 },
    { at: 0.7, value: -0.2 },
    { at: 1, value: 0 },
  ],
});

For closed routes, the first and last value of each profile must match. Place keys at both 0 and 1, with strictly increasing at values. Constants are valid for either open or closed routes. The optional borders follow the local width and bank automatically.

Place things by distance

javascript
for (let distance = 0; distance < track.length; distance += 0.5) {
  const frame = track.sampleDistance(distance);
  const sleeper = makeSleeper(frame.width + 0.3); // Your mesh factory.
  sleeper.position.copy(frame.position);
  sleeper.quaternion.copy(frame.quaternion);
  scene.add(sleeper);
}

Sampling always returns coordinates in Loom's local space. For a translated/rotated Loom and a train directly under an identity scene, use track.localToWorld(frame.position) and compose track.getWorldQuaternion(worldQuaternion).multiply(frame.quaternion). The host must also transform distances/offsets when scaling the route. Prefer uniform scale: nonuniform scale does not preserve orthonormal frames or local arc-length distances in world space.

Save and restore a route

javascript
const json = JSON.stringify(track.toRecipe(), null, 2);
const restored = Loom.fromRecipe(json);
scene.add(restored);

// Rebuild an existing route while retaining mesh and material identity.
track.configure({ segments: 256, width: 1.7 });

// Remove and release the geometry when finished.
track.dispose();
restored.dispose();

configure validates and constructs the replacement before changing the existing route. Invalid input leaves the old geometry and sampling data usable. Successful rebuilds dispose the old geometries. The recipe is { schema: 'cranberry-forge.loom/1', options: ... }, containing normalized plain JSON data. It excludes materials, scene transforms, train state, and workshop scenery.

View versioned source on GitHub ↗