interactive spirograph (WebGL2)
draw hypotrochoid / epitrochoid curves in the browser. no build step.
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.
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
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.
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
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
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.
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
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
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
uwhere every harmonicfsatisfies|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 withinmax 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
speedanddiameterare 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
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
WebGL2, no framework
no internet links in code. if need any lib to run - download it and link as js file.
modern browser with WebGL2 support
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.
on exit save to localStorage, restored on load. if localStorage failed - should be 'autosave unavailable' label near save buttons in GUI
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).
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`
MIT

