Skip to content

Repository files navigation

interactive spirograph (WebGL2)

draw hypotrochoid / epitrochoid curves in the browser. no build step.

screenshot

TODO and notes

center selected gear should be disabled by default or replaced by follow gear mechanics. add checkboxes (off by default):

  • follow selected gear
  • follow gear angle
  • keep angle 'selected gear'-'parent gear'-'viewport center'

glass shader: analytical layered glass works different when circles are off - produce strange opaque spheres - for now count it as feature and do not plan to fix.

features summary

interactive parent-child gear tree, add / remove sub-gears
panel and gear menu are organised in striped groups (playback, scene,
  dimension, curve, view, background, spheres, tree / geometry, pen,
  trail, view, gears): a title band over zebra rows, so a section is found
  by its shape and not by reading every label
gear tree level sliders (lvl 1..N, starting at 0): every parent at a depth
  gets N children placed at i*360/N degrees; 0 removes the level and
  everything below it; new siblings deep-clone the template sub-tree;
  `reset levels` collapses the tree to a single chain
gear list under the level sliders: one row per gear, grouped by level and
  labelled with its place in the tree (#root.child.child), its diameter,
  speed and pencil colors. clicking a row opens that gear's menu (and in
  3D puts the orbit camera on it); picking a gear on the canvas lights the
  same row up, and a menu edit rewrites the row (a diameter resizes the
  rows of the whole sub-tree mounted on it)
symmetry mode: context-menu edits mirror to every gear at the same level;
  add-sub-gear grows the whole level; a symmetric scene SAVES as one gear
  per level (that level's template) plus the per-level counts, so save
  files stay small no matter how many copies the rosette has
per-gear: internal/external, speed -1..1, diameter, pencil offset d, width,
  two color slots (each with its own enable checkbox) -> 0 colors = no pencil,
  1 color = static, 2 colors = animated blend between them
  global cycles/frequency color mode (auto per trace mode, user-overridable)
animated guide circles: the `circles` outlines can ride their colour on how
  far a gear centre is from an ANCHOR gear (`hue anchor`: its parent, the
  parent's parent, or the root) or on how fast that distance changes. the
  parent anchor is constant by construction - the mount is rigid, so the
  centre distance is |R ± r| forever, in 2D and in tilted 3D alike - which
  tints the gears apart; a farther anchor is what makes the hue move
per-pencil trail length (how much of the animate trail stays on screen;
  rings grow lazily) + a separate whole-mode `detail` (points per turn)
glass spheres view option: a `glass shader` selector dresses every gear
  in a ray-traced hollow glass shell (full-screen analytic trace, no
  geometry, exact depth order - nested gears and a camera inside a shell
  just work). two shaders, each with its own slider set (ports of
  https://github.com/luncat8/glass-spheres-shader.git, spheres only): `hollow glass bubbles`
  (membrane wall traced through up to 10 layers, Fresnel, Beer-Lambert
  tint, thin-film iridescence, dispersion) and `analytic layered glass`
  (nearest three shells composited); plus a sphere tint color picker.
  the scene behind the glass (trails) refracts through the shells
whole-curve mode with tolerance-based period detection, background
  (time-sliced) baking with a progress readout, and sliders that only stop
  on period-friendly values; `max period` sets the search ceiling
zoom / pan, pause, clear, reset to default scene
save scene as a .js file (rename to default.js for startup scene) / load
  .js or .json (file or clipboard), autosave to localStorage
60+ FPS pan/zoom in both modes: direct-draw during gestures, one overlay
  re-bake on release, auto-tuned decimation on weak GPUs
3D mode: a genuine two-axis generalization - every gear is a sphere that
  precesses about TWO axes (in-plane `speed` + per-gear tilt `speed2`),
  nested 3D orientation frames thread down the gear tree; trails are real
  3D point streams (x y z rgb), gears draw as sphere-outline circles; the
  orbit camera (drag orbit, wheel/pinch dolly, right/middle drag pan,
  auto-rotate, fit) pivots on the selected gear's sphere centre (else the
  root); trails are painted in stroke order (no depth sorting: the line
  shader emits a flat z, so nearer curves do not occlude farther ones);
  with all tilt speeds 0 the figure is exactly the flat 2D curve
  standing upright
zero dependencies, WebGL2, runs from file://
presets: the save button writes a descriptively named scene file
  (`3d-tails-whole-yy-mm-dd-hh-mm-ss.js`: dimension, trail drawn, whole
  mode, timestamp). two ways to make them appear in the panel's preset
  dropdown (run the script from the directory holding index.html):
  - `presets_merge_to_default.js.py` (single file): the saved scene files
    are appended to `default.js` (preset name = file name, consumed files
    renamed `*.delete-me` so a second run adds nothing twice)
  - `link_presets_to_html.py` (multi file): the scene files stay where they
    are and are inserted into index.html as `<script>` tags between
    `default.js` and `js/main.js`; each file gains a `// preset: NAME.js`
    marker, so renaming or deleting a file and re-running moves or drops
    its tag. preset files append to `window.PRESETS` and never touch the
    startup `SETTINGS`
  picking a dropdown entry loads the scene exactly as saved (gears,
  settings, dimension, camera); opening a linked preset file via `o` loads
  it too. `default.js` still provides the startup scene whenever there is
  no autosave

run

no build. run with double click in file explorer.

file:// friendly. no server need. load js script tags, not ES modules.

for node debugging, guard module.exports so the same files run in both.

layout

square canvas, size = short side of window (snapped to mod 64px texture)

GUI sits left for wide windows, top for tall windows. its controls are split into titled groups with striped rows (playback, scene, dimension, curve, view, background, spheres, tree) and the gear context menu uses the same boxes (geometry, pen, trail, view, gears) - a group is one gui.js group() call, the stripes are pure CSS, so nothing about the controls themselves changes

zoom / pan with pointer, touch and mouse wheel

default scene: main gear is 60% of canvas, pivot fixed at center

GUI has a global animation speed slider

model

gears form a parent-child tree

main gear = root, pivot fixed in place
each child gear attached to parent, rotates around it pivot
each gear has: radius (size vs parent), speed -1..1, optional pencil at offset d

curve = path traced by an enabled pencil over time

classic params: R fixed, r rolling, d pen offset -> hypotrochoid / epitrochoid

math

parametric curve, θ = rotation angle of rolling gear:

hypotrochoid (inside):
	x = (R - r) cos θ + d cos(((R - r)/r) θ)
	y = (R - r) sin θ - d sin(((R - r)/r) θ)
epitrochoid (outside):
	x = (R + r) cos θ - d cos(((R + r)/r) θ)
	y = (R + r) sin θ - d sin(((R + r)/r) θ)

in this app R,r come from each gear radius vs its ancestor chain; internal/external picks the sign.

use case

mouse L and mouse center btn - pan. right mouse btn keep native browser behavior to be possible save image, etc. mouse left btn on gear - show context menu.

from this menu user can:

switch is this gear internal or external relative to parent
sliders: gear diameter and rotation speed -1...1
move slider of marker pencil position (distance from center of this gear)
slider of pencil line width
slider of trail length (how many points of the animate trail stay on
screen; hidden in whole mode, where the curve is the whole closed figure
and its smoothness is the sidebar `detail` slider)
two color slots, each with its own enable checkbox placed before the picker:
	if none enabled  -> no pencil is drawn
	if one enabled    -> static single color
	if both enabled   -> animated blend between the two colors (speed slider)
place sub-gear
remove this gear (and its children)

the left panel has a tree section:

symmetry mode checkbox - when on, every context-menu edit above applies
to all sibling gears at the same level (added/removed in sync); turning
it on commits the tree to uniform rosettes (each level = clones of its
first gear, re-spread), and a remove shrinks the whole level. saves made
while it is on store one gear per level + the per-level counts - old
full-format symmetric saves re-commit themselves on load
lvl 1..N sliders - set how many children every parent at that depth has;
children are positioned evenly around their parent (i * 360/N degrees).
the slider range starts at 0: dragging a level to 0 empties it (and every
level below it), which is how a level is removed. lvl k+1 appears once
level k has sub-gears; a 400-gear guard blocks a runaway 12 x 12 x 12.
reset levels button - collapse every level to a single child
gear list under the sliders - one row per gear, grouped by level, each
row labelled `#root.child.child` with its diameter and speed and dotted
with its pencil colors. clicking a row opens that gear's context menu (in
3D the orbit camera moves to it, exactly as with a canvas pick), and a
gear picked on the canvas lights its row up - the list is the tree, not a
snapshot: it rebuilds with every structural change and follows menu edits

and a whole mode section (visible in whole mode):

period readout - `period: 132 turns` (or `~132 turns (approx, gap ...)`
when nothing closes exactly), plus `- baking NN%` while the background
bake runs
max period slider (4..4000) - the CEILING of the closure search, not a
target. the readout shows the smallest turn count that closes the figure,
which on the whole-mode gear grid is usually 30..200; lower the ceiling to
cut a long figure short (drawn, marked ~approx), raise it to let a long
one close.
detail slider (20..2000 points per turn) - smoothness of the baked curve
(period x detail points, capped at 40000 per pencil)

drag context menu using pointer. move by drag caption 'gear' label with unicode ico ✥ near it

middle mouse pan

simulation

every frame

gears rotate according speed, properly calculate position and rotation with parent and child gears.
enabled pencils draw smooth line from last to current position

render

screenshot whole period mode

smooth antialiased lines

draw modes:

  • animated draw similar to pencil using last N segments or FBO draw

    two render modes, both in 2D and 3D (bake full figure (overlay) checkbox):

    keep (overlay ON, default) - the trail is baked into a cached framebuffer once per view; every frame only the points pushed since the last bake are appended, then the cache is blitted under the live gear skeleton. any change of the view (2D pan/zoom, 3D orbit / dolly / pan / fit / auto-rotate / orbit pivot) invalidates the cache and it is re-baked once the gesture settles (during the gesture the trail is drawn directly, decimated when it is bigger than the device budget).

    redraw (overlay OFF) - the whole ring (bounded by the trail length) is re-projected and redrawn from scratch on every render. same picture as keep, more GPU work per frame, no cache to invalidate.

  • calculate whole line and update interactively while move sliders. properly detect period and improve sliders to fit periods

    period detection is a closure scan, not an exact LCM: the figure is a sum of rotating vectors, and the app looks for the smallest number of turns u where every harmonic f satisfies |frac(f*u)| * 2pi * amplitude <= ~0.5px. that is continuous in the parameters (a hair-thin diameter change no longer multiplies the period by 100), bounded in cost, and always answers: if nothing closes within max period, the best candidate is drawn and the readout marks it ~N turns (approx).

    the bake itself is a resumable job stepped from the frame loop in ~6ms slices, so the UI never freezes and the figure appears progressively. while a slider is dragged a quarter-resolution draft is baked and refined once the drag stops.

    in this mode speed and diameter are index sliders over the discrete set of values that keep the period short (speed = +-k/d with d <= 12, diameter = a rational multiple of the parent diameter), so every reachable position is a valid one.

  • without circles but only 'dial' lines from center. look how it draw it - it visually good looks

  • only glowing pencil points (no traces, no center of gears)

change (animate) pencil color hue. not blend all but: example color 1 = Y color 2 = R animation should be Y...G...B...R light and saturation just blend

properly change color of pencil:

for r,b r-g-b-g-r-r-g-b-g-... for b,r b-r-b-r-... color1-color2 is not same as color2-color1. and it should not do whole hue wheel

dependencies

WebGL2, no framework

no internet links in code. if need any lib to run - download it and link as js file.

requirements

modern browser with WebGL2 support

controls

space - pause / resume
wheel or gesture - zoom (2D) / dolly (3D)
drag - pan (2D); left-drag orbits in 3D, right/middle-drag pans in both
click / hover gear axis - context menu
Esc - close context menu (also auto-closes)

GUI buttons, also keyboard shortcuts:
c - clear canvas
x - reset objects to default scene + view transform
g - toggle 2D / 3D
f - fit camera (3D)
s - copy scene model (gears, view) json to clipboard
d - download scene model as a descriptively named .js file
  (2d/3d-tails-whole-timestamp.js; presets_merge_to_default.js.py or
  link_presets_to_html.py make it a preset)
o - load scene model from file (.js or legacy .json)
p - load scene model from clipboard

in 3D the panel shows a 3D section: an auto-rotate-camera toggle with two log-scale speed sliders (auto yaw, auto pitch; 0 = that axis stays still, then 0.01..3 rad/s - the pitch drift bounces at the clamp instead of pinning at the pole) and fit / reset-camera buttons. the toggle and both speeds are persisted in the scene / autosave, so a saved 3D scene resumes its camera motion on load. the second rotation axis is PER GEAR - each gear's context menu gains a tilt speed slider (its own precession speed, 0 = stays in plane; snapped to period-friendly values in whole mode so the baked 3D figure closes). opening a gear menu makes the orbit camera pivot on that gear's sphere centre; closing it returns the pivot to the root. double-click empty space (or f) reframes; the menu also has a "view" quick row.

state

on exit save to localStorage, restored on load. if localStorage failed - should be 'autosave unavailable' label near save buttons in GUI

scene format

scene saved/loaded by s / d / o / p: clipboard uses json; the file format (d) wraps the same object in a SETTINGS js module so a saved scene doubles as a preset (presets_merge_to_default.js.py merges it into default.js, link_presets_to_html.py links the file in place) or can be renamed to default.js to become the startup scene. linked preset files use the same object but export it as a PRESETS entry instead of SETTINGS. a scene saved with symmetry on stores one gear per level (the level template) plus a top-level levels array of per-level child counts; the loader re-expands it, and full saves (no levels) load exactly as before. legacy .json files still load. colors are hex strings, parsed to rgb floats internally for webgl, example:

{
  "gears": [ { "r": 0.6, "speed": 0.2, "internal": false,
               "phase0": 0, "rot": 0, "trailCap": 20000,
               "pencil": { "d": 0.4, "width": 2,
                           "c1": { "on": true, "color": "#ff0000" },
                           "c2": { "on": false, "color": "#0000ff" },
                           "animSpeed": 0.1 },
               "children": [] } ],
  "view": { "zoom": 1, "pan": [0, 0] },
  "globalSpeed": 1,
  "colorMode": "frequency",
  "dim": "2d",
  "camera": null
}

phase0 (constant mount offset of the gear around its parent, radians - what the level sliders use to build a rosette), rot (live orbit angle), speed2 (3D tilt/precession speed, -1..1, per gear; 0 = stays in plane) and trailCap (soft cap on stored trail points) are optional; legacy files without them default to 0 / 0 / 0 / 20000. the app block additionally carries maxPeriod (legacy files may carry the old periodThreshold, which maps onto it). the top-level dim ("2d" / "3d") and camera ({yaw,pitch,dist,target:[x,y,z]}) are 3D fields, all optional - missing values default to 2D / a refit camera (a 3D file with no camera refits on entry).

structure

README.md - this file
AGENTS.md - guidance for LLM agents
index.html - entry point (classic <script> tags, no build)
default.js - startup scene (SETTINGS) + preset list (PRESETS) for the
  panel dropdown; regenerated by presets_merge_to_default.js.py
presets_merge_to_default.js.py - merge the scene files saved in this
  directory into default.js's PRESETS (preset name = file name; consumed
  files are renamed *.delete-me; files linked into index.html are left
  alone). run it from the directory holding index.html
link_presets_to_html.py - the multi-file preset half: convert the
  scene files in this directory into preset modules (marker line +
  PRESETS export), insert/manage their <script class="preset"> tags in
  index.html, and keep the tags in sync when files are renamed or
  deleted. run it from the directory holding index.html
implementation-log.txt - what is already done and next step for LLM agents
findings-pitfalls-skills.md - notes for LLM agents
CHANGELOG.md - short release notes
js/settings.js - single source of truth for slider bounds + the persisted
  app-state schema (defaults, loader coercion, apply recipes), store key
js/main.js - init, loop
js/gear.js - gear math
js/render.js - webgl2 line drawing (analytic AA, overlay FBO, depth)
js/camera3.js - 3D orbit camera (pure mat4 math, no DOM)
js/gui.js - panel, context menu, sliders
test/run.js - headless checks: `node test/run.js`
test/preview.js - offline PNG render of a bake: `node test/preview.js out.png`

license

MIT

About

2d 3d spirograph, vanilla js, webGL, period detection, color change, adjust parameters

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages