BallOfLife

This commit is contained in:
Sam
2026-09-26 01:13:52 +12:00
parent 5c8c8e1591
commit f2ff90d42c
8 changed files with 368 additions and 9 deletions
+28
View File
@@ -0,0 +1,28 @@
Run the app from this directory:
```sh
python3 serve.py --port 5501
```
Open http://localhost:5501/wave-machine or
http://localhost:5501/minds-eye-is-full-of-flowers.
Preset names are case-insensitive; replace spaces with hyphens (URL-encoded
spaces also work). Loading a preset from the Presets menu updates the URL,
and browser Back/Forward loads the corresponding preset.
Built-in presets work in any browser. Saved presets only work where they are
stored in localStorage, on the same browser and origin (including port).
Unknown names show a message and leave an empty scene.
Use this server for direct links and refreshes. An alternative host must serve
`index.html` for preset paths; trailing slashes should redirect to the same path
without the slash so relative assets load correctly.
For **ball of life**, enter cylinder height/radius, ball radius, and ground line
length in centimetres. Point spacing is also in centimetres (10 means one point
every 10 cm). A 1000 cm column at 10 cm spacing has 101 points including the
base and top. Ground lines continue from the base without duplicating its point.
If a length is not divisible by its spacing, the remaining end section is empty.
Ball spacing follows each longitude from pole to pole along the surface; both
poles are included, so the final gap may be shorter. Longitude lines remain a
total count. Zoom changes only the view, not the physical dimensions or counts.
+1 -1
View File
@@ -979,4 +979,4 @@ canvas {
.shape-picker-modal { .shape-picker-modal {
width: 100%; width: 100%;
} }
} }
+4 -3
View File
@@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<head> <head>
<title>mathfunk</title> <title>mathfunk</title>
<link rel="stylesheet" href="./css/styles.css"> <link rel="stylesheet" href="./css/styles.css?v=cm-sliders-3">
</head> </head>
<body style="margin:0;"> <body style="margin:0;">
@@ -43,12 +43,13 @@
<!-- Control system --> <!-- Control system -->
<script src="./js/controls/FilterManager.js" type="text/javascript"></script> <script src="./js/controls/FilterManager.js" type="text/javascript"></script>
<script src="./js/controls/ControlFactory.js" type="text/javascript"></script> <script src="./js/controls/ControlFactory.js?v=cm-sliders-3" type="text/javascript"></script>
<script src="./js/controls/ControlManager.js" type="text/javascript"></script> <script src="./js/controls/ControlManager.js" type="text/javascript"></script>
<script src="./js/presets.js" type="text/javascript"></script> <script src="./js/presets.js" type="text/javascript"></script>
<script src="./js/controls/SceneUI.js" type="text/javascript"></script> <script src="./js/controls/SceneUI.js" type="text/javascript"></script>
<!-- Shape definitions --> <!-- Shape definitions -->
<script src="./js/shapes/BallOfLife.js?v=lighting-1" type="text/javascript"></script>
<script src="./js/shapes/PolyTwistColourWidth.js" type="text/javascript"></script> <script src="./js/shapes/PolyTwistColourWidth.js" type="text/javascript"></script>
<script src="./js/shapes/FloralPhyllo.js" type="text/javascript"></script> <script src="./js/shapes/FloralPhyllo.js" type="text/javascript"></script>
<script src="./js/shapes/SpiralWormhole.js" type="text/javascript"></script> <script src="./js/shapes/SpiralWormhole.js" type="text/javascript"></script>
@@ -69,4 +70,4 @@
<!-- Main application --> <!-- Main application -->
<script src="./js/index.js" type="text/javascript"></script> <script src="./js/index.js" type="text/javascript"></script>
</html> </html>
+6 -5
View File
@@ -87,7 +87,7 @@ class ControlFactory {
// Label showing property name // Label showing property name
const label = document.createElement('span'); const label = document.createElement('span');
label.className = 'control-label-inline'; label.className = 'control-label-inline';
label.textContent = config.property; label.textContent = config.label || config.property;
// Value display (editable) // Value display (editable)
const valueDisplay = document.createElement('span'); const valueDisplay = document.createElement('span');
@@ -102,6 +102,7 @@ class ControlFactory {
input.id = `el${config.property}`; input.id = `el${config.property}`;
input.min = config.min; input.min = config.min;
input.max = config.max; input.max = config.max;
input.step = config.step ?? 1;
input.value = instance[config.property] ?? config.defaultValue; input.value = instance[config.property] ?? config.defaultValue;
// Store original config bounds for reference // Store original config bounds for reference
@@ -296,7 +297,7 @@ class ControlFactory {
const label = document.createElement('span'); const label = document.createElement('span');
label.className = 'control-label-inline'; label.className = 'control-label-inline';
label.textContent = config.property; label.textContent = config.label || config.property;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'color'; input.type = 'color';
@@ -351,7 +352,7 @@ class ControlFactory {
const label = document.createElement('span'); const label = document.createElement('span');
label.className = 'control-label-inline'; label.className = 'control-label-inline';
label.textContent = config.property; label.textContent = config.label || config.property;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
@@ -393,7 +394,7 @@ class ControlFactory {
const label = document.createElement('p'); const label = document.createElement('p');
label.className = 'control-label'; label.className = 'control-label';
label.id = `elText${config.property}`; label.id = `elText${config.property}`;
label.textContent = `${config.property}: ${config.defaultValue}`; label.textContent = config.label || `${config.property}: ${instance[config.property] ?? config.defaultValue}`;
const select = document.createElement('select'); const select = document.createElement('select');
select.className = 'control control-dropdown'; select.className = 'control control-dropdown';
@@ -410,7 +411,7 @@ class ControlFactory {
const listener = (event) => { const listener = (event) => {
const newValue = event.target.value; const newValue = event.target.value;
instance[config.property] = newValue; instance[config.property] = newValue;
label.textContent = `${config.property}: ${newValue}`; label.textContent = config.label || `${config.property}: ${newValue}`;
if (config.callback) { if (config.callback) {
config.callback(instance, newValue); config.callback(instance, newValue);
} }
+33
View File
@@ -213,6 +213,39 @@ class SceneUI {
if (index >= 0 && index < presets.length) { if (index >= 0 && index < presets.length) {
this.scene.importFromJSON(presets[index].data, this); this.scene.importFromJSON(presets[index].data, this);
this.hidePresetsModal(); this.hidePresetsModal();
const name = this.presetSlug(presets[index].name);
if (name && this.presetBasePath !== undefined) {
history.pushState(null, '', this.presetBasePath + encodeURIComponent(name));
}
}
}
presetSlug(name) {
return name.trim().toLowerCase().replace(/\s+/g, '-');
}
loadPresetFromPath() {
const path = window.location.pathname;
if (!path.startsWith(this.presetBasePath)) return;
let name;
try {
name = decodeURIComponent(path.slice(this.presetBasePath.length).replace(/\/$/, ''));
} catch {
return;
}
if (!name || name === 'index.html') {
this.scene.clear();
this.clearAllPanels();
return;
}
const presets = [...this.getBuiltInPresets(), ...this.getPresets()];
const preset = presets.find(item => this.presetSlug(item.name) === this.presetSlug(name));
if (preset) {
this.scene.importFromJSON(preset.data, this);
} else {
this.scene.clear();
this.clearAllPanels();
alert(`Preset "${name}" was not found. Choose a preset from the Presets menu.`);
} }
} }
+3
View File
@@ -25,6 +25,9 @@ engine.setScene(scene);
// Initialize Scene UI // Initialize Scene UI
const sceneUI = new SceneUI(scene, 'layers-container'); const sceneUI = new SceneUI(scene, 'layers-container');
sceneUI.presetBasePath = new URL('../', document.currentScript.src).pathname;
sceneUI.loadPresetFromPath();
window.addEventListener('popstate', () => sceneUI.loadPresetFromPath());
// ============================================ // ============================================
// Event Listeners // Event Listeners
+252
View File
@@ -0,0 +1,252 @@
/**
* BallOfLife - A cylinder supporting a sphere, projected onto the 2D canvas.
*/
class BallOfLife extends BaseShape {
static config = [
{ type: 'header', text: 'Point spacing & dimensions (cm)' },
{ type: 'range', min: 1, max: 100, defaultValue: 6, property: 'pointsPerRow', label: 'Columns around cylinder (total)' },
{ type: 'range', min: 1, max: 1000, step: 0.1, defaultValue: 30.4, property: 'columnSpacingCm', label: 'Column: point every (cm)' },
{ type: 'range', min: 1, max: 12, defaultValue: 3, property: 'pointSize' },
{ type: 'color', defaultValue: [255, 53, 53], property: 'pointColor' },
{ type: 'header', text: 'Cylinder' },
{ type: 'range', min: 1, max: 3000, step: 0.1, defaultValue: 103.9, property: 'cylinderRadiusCm', label: 'Cylinder radius (cm)' },
{ type: 'range', min: 1, max: 10000, step: 0.1, defaultValue: 1000, property: 'columnHeightCm', label: 'Column height (cm)' },
{ type: 'range', min: -360, max: 360, defaultValue: 0, property: 'twist' },
{ type: 'header', text: 'Sphere' },
{ type: 'range', min: 1, max: 3000, step: 0.1, defaultValue: 300, property: 'sphereRadiusCm', label: 'Ball radius (cm)' },
{ type: 'range', min: 1, max: 1000, step: 0.1, defaultValue: 30.4, property: 'longitudeSpacingCm', label: 'Longitude: point every (cm)' },
{ type: 'range', min: 3, max: 100, defaultValue: 17, property: 'longitudeCount', label: 'Longitude lines (total)' },
{ type: 'header', text: 'View' },
{ type: 'range', min: -80, max: 80, defaultValue: 7, property: 'tilt' },
{ type: 'range', min: 10, max: 200, defaultValue: 124, property: 'zoomPercent' },
{ type: 'range', min: 0, max: 100, defaultValue: 50, property: 'perspectiveStrength' },
{ type: 'header', text: 'Rings' },
{ type: 'checkbox', defaultValue: true, property: 'showRings' },
{ type: 'color', defaultValue: [191, 199, 213], property: 'ringColor' },
{ type: 'range', min: 1, max: 8, defaultValue: 1.5, property: 'ringWidth' },
{ type: 'header', text: 'Ground Points' },
{ type: 'checkbox', defaultValue: true, property: 'showGroundSpray' },
{ type: 'range', min: 1, max: 1000, step: 0.1, defaultValue: 30.4, property: 'groundSpacingCm', label: 'Ground: point every (cm)' },
{ type: 'range', min: 0, max: 10000, step: 0.1, defaultValue: 900, property: 'groundLengthCm', label: 'Ground line length (cm)' },
{ type: 'color', defaultValue: [255, 53, 53], property: 'sprayColor' },
{ type: 'range', min: 1, max: 12, defaultValue: 3, property: 'groundPointSize' },
{ type: 'header', text: 'Animation' },
{ type: 'dropdown', defaultValue: 'steady', property: 'lightingEffect', label: 'Lighting effect', options: [
{ value: 'steady', label: 'Steady (no animation)' },
{ value: 'pulse', label: 'Pulse' },
{ value: 'wave', label: 'Rising wave' },
{ value: 'chase', label: 'Rotating chase' },
{ value: 'sparkle', label: 'Sparkle' },
{ value: 'rainbow', label: 'Rainbow flow' },
] },
{ type: 'range', min: 0, max: 300, defaultValue: 100, property: 'lightingSpeed', label: 'Lighting speed (%)' },
{ type: 'checkbox', defaultValue: false, property: 'autoRotate' },
{ type: 'range', min: 0, max: 200, defaultValue: 100, property: 'rotateSpeed' },
{ type: 'checkbox', defaultValue: false, property: 'reverseRotation' },
{ type: 'header', text: 'Stickman (scale reference)' },
{ type: 'checkbox', defaultValue: true, property: 'showStickman', label: 'Show stickman' },
{ type: 'range', min: 1, max: 300, step: 0.1, defaultValue: 175, property: 'stickmanHeightCm', label: 'Stickman height (cm)' },
];
constructor(...values) {
super();
// Match the registry's positional config arguments.
BallOfLife.config.filter(item => item.property).forEach((item, index) => {
const value = values[index] ?? item.defaultValue;
this[item.property] = Array.isArray(value) ? [...value] : value;
});
}
// Apply a 3x3 rotation matrix, then perspective divide. Strength 0 is orthographic.
project(x, y, z, matrix, scale) {
const rx = matrix[0] * x + matrix[1] * y + matrix[2] * z;
const ry = matrix[3] * x + matrix[4] * y + matrix[5] * z;
const rz = matrix[6] * x + matrix[7] * y + matrix[8] * z;
const halfHeight = this.columnHeightCm / 600;
const bound = Math.max(
Math.hypot(this.cylinderRadiusCm / 300 +
(this.showGroundSpray ? Math.max(0, this.groundLengthCm) / 300 : 0), halfHeight),
halfHeight + 2 * this.sphereRadiusCm / 300,
this.showStickman ? Math.hypot(
this.cylinderRadiusCm / 300 + this.stickmanHeightCm / 300 * 0.6,
halfHeight + this.stickmanHeightCm / 300
) : 0
);
// Keep the shapes and ground spray in front of the camera at maximum perspective.
const distance = Math.max(6, bound * 2.5);
const strength = Math.max(0, Math.min(100, this.perspectiveStrength)) / 50;
const perspective = distance / (distance + rz * strength);
return {
x: centerX + rx * scale * perspective,
y: centerY + ry * scale * perspective,
depth: rz,
perspective,
worldX: x,
worldY: y,
worldZ: z,
};
}
draw(elapsed) {
this.updateFilters(elapsed);
const columns = Math.max(1, Math.min(100, Math.round(this.pointsPerRow)));
const rows = Math.floor(this.columnHeightCm / this.columnSpacingCm + 1e-9) + 1;
const direction = this.reverseRotation ? -1 : 1;
const angle = this.autoRotate ? elapsed * this.speedMultiplier / 200 * (this.rotateSpeed / 100) * direction : 0;
const tilt = this.tilt * Math.PI / 180;
const ca = Math.cos(angle), sa = Math.sin(angle);
const ct = Math.cos(tilt), st = Math.sin(tilt);
// R_x(tilt) * R_y(angle): spin around the cylinder axis, then tilt the view.
const matrix = [ca, 0, sa, st * sa, ct, -st * ca, -ct * sa, st, ct * ca];
const scale = Math.min(ctx.canvas.width / 6, ctx.canvas.height / 10) * this.zoomPercent / 100;
const radius = this.cylinderRadiusCm / 300;
const halfHeight = this.columnHeightCm / 600;
const points = [];
for (let row = 0; row < rows; row++) {
// Start at the base and keep the requested spacing exactly.
const fraction = 1 - row * this.columnSpacingCm / this.columnHeightCm;
const y = (2 * fraction - 1) * halfHeight;
for (let column = 0; column < columns; column++) {
const theta = 2 * Math.PI * column / columns + fraction * this.twist * Math.PI / 180;
points.push(this.project(radius * Math.cos(theta), y, radius * Math.sin(theta), matrix, scale));
}
}
const sphereRadius = this.sphereRadiusCm / 300;
// Seat the sphere in the hollow rim: radius² + centerOffset² = sphereRadius².
// A sphere narrower than the opening has no rim contact; place its equator at the rim.
const centerOffset = Math.sqrt(Math.max(0, sphereRadius * sphereRadius - radius * radius));
const sphereCenterY = -halfHeight - centerOffset;
const arcLength = Math.PI * this.sphereRadiusCm;
const latitudes = Math.ceil(arcLength / this.longitudeSpacingCm) + 1;
const longitudes = Math.max(3, Math.min(100, Math.round(this.longitudeCount)));
for (let latitude = 0; latitude < latitudes; latitude++) {
// Latitude runs from -90 to +90 degrees, including one point at each pole.
const phi = -Math.PI / 2 + Math.min(latitude * this.longitudeSpacingCm / this.sphereRadiusCm, Math.PI);
const pole = latitude === 0 || latitude === latitudes - 1;
const ringRadius = pole ? 0 : sphereRadius * Math.cos(phi);
const y = sphereCenterY + sphereRadius * Math.sin(phi);
const count = pole ? 1 : longitudes;
for (let longitude = 0; longitude < count; longitude++) {
const theta = 2 * Math.PI * longitude / longitudes;
points.push(this.project(ringRadius * Math.cos(theta), y,
ringRadius * Math.sin(theta), matrix, scale));
}
}
ctx.save();
if (this.showGroundSpray) {
const count = Math.floor(this.groundLengthCm / this.groundSpacingCm + 1e-9);
const length = Math.max(0, this.groundLengthCm) / 300;
for (let column = 0; column < columns; column++) {
// Match the twist at the cylinder base.
const theta = 2 * Math.PI * column / columns +
this.twist * Math.PI / 180;
for (let point = 1; point <= count; point++) {
// Continue outward from the base point without duplicating it.
const distance = radius + point * this.groundSpacingCm / 300;
if (length === 0) break;
const p = this.project(distance * Math.cos(theta), halfHeight,
distance * Math.sin(theta), matrix, scale);
points.push({ ...p, color: this.sprayColor, size: this.groundPointSize });
}
}
}
// Project circular rims using the same transform as the points.
ctx.strokeStyle = Array.isArray(this.ringColor) ? `rgb(${this.ringColor.join(',')})` : this.ringColor;
ctx.lineWidth = this.ringWidth;
for (const y of this.showRings ? [-halfHeight, halfHeight] : []) {
ctx.beginPath();
for (let i = 0; i < 128; i++) {
const theta = 2 * Math.PI * i / 128;
const p = this.project(radius * Math.cos(theta), y, radius * Math.sin(theta), matrix, scale);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.stroke();
}
// Paint far points first; perspective makes nearer points appear larger.
const lightingTime = elapsed * (this.speedMultiplier / 100) * (this.lightingSpeed / 100);
const baseAlpha = ctx.globalAlpha;
points.sort((a, b) => b.depth - a.depth);
for (const p of points) {
const lighting = this.pointLighting(p, lightingTime);
const color = lighting.color ?? p.color ?? this.pointColor;
ctx.globalAlpha = baseAlpha * lighting.brightness;
ctx.fillStyle = Array.isArray(color) ? `rgb(${color.join(',')})` : color;
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(0.5, p.size ?? this.pointSize) * p.perspective, 0, 2 * Math.PI);
ctx.fill();
}
ctx.globalAlpha = baseAlpha;
if (this.showStickman) {
this.drawStickman(halfHeight, radius, [1, 0, 0, 0, ct, -st, 0, st, ct], scale);
}
ctx.restore();
}
pointLighting(point, time) {
const turn = 2 * Math.PI;
const height = this.columnHeightCm / 600 - point.worldY;
const radialDistance = Math.hypot(point.worldX, point.worldZ);
const theta = Math.atan2(point.worldZ, point.worldX);
// Use physical coordinates so patterns remain attached to points while rotating.
const travel = height - radialDistance;
switch (this.lightingEffect) {
case 'pulse':
return { brightness: 0.15 + 0.85 * (0.5 + 0.5 * Math.sin(time * turn)) };
case 'wave':
return { brightness: 0.12 + 0.88 * Math.pow(0.5 + 0.5 * Math.cos(travel * 3 - time * turn), 4) };
case 'chase':
return { brightness: 0.12 + 0.88 * Math.pow(0.5 + 0.5 * Math.cos(theta - time * turn), 8) };
case 'sparkle': {
const seed = Math.sin(point.worldX * 127.1 + point.worldY * 311.7 + point.worldZ * 74.7) * 43758.5453;
const phase = seed - Math.floor(seed);
return { brightness: 0.1 + 0.9 * Math.pow(0.5 + 0.5 * Math.sin(time * turn * (0.7 + phase) + phase * turn), 16) };
}
case 'rainbow': {
const hue = ((travel * 65 + theta * 180 / Math.PI - time * 90) % 360 + 360) % 360;
return { brightness: 1, color: `hsl(${hue}, 100%, 60%)` };
}
default:
return { brightness: 1 };
}
}
drawStickman(groundY, radius, matrix, scale) {
const height = this.stickmanHeightCm / 300;
const center = radius + height * 0.4;
// Keep the figure beside the cylinder as it spins, on the same ground plane.
const project = (x, y) => this.project(center + x * height, groundY - y * height, 0, matrix, scale);
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = Math.max(1, height * scale * 0.025);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= 48; i++) {
const angle = i / 48 * Math.PI * 2;
const p = project(0.09 * Math.cos(angle), 0.91 + 0.09 * Math.sin(angle));
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
for (const limb of [
[[0, 0.82], [0, 0.43]],
[[-0.2, 0.48], [0, 0.72], [0.2, 0.48]],
[[-0.17, 0], [0, 0.43], [0.17, 0]],
]) {
ctx.beginPath();
limb.forEach(([x, y], index) => {
const p = project(x, y);
if (index === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
}
}
}
shapeRegistry.register('ball of life', BallOfLife);
+41
View File
@@ -0,0 +1,41 @@
"""Serve the animation app with direct preset URLs (no dependencies)."""
import argparse
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parent
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **kwargs)
def send_head(self):
url = urlsplit(self.path)
target = Path(self.translate_path(url.path))
# Only single-segment routes are preset names; missing assets stay 404s.
name = url.path.strip('/')
if name and '/' not in name and not target.exists():
if url.path.endswith('/'):
self.send_response(307)
self.send_header('Location', url.path.rstrip('/') + ('?' + url.query if url.query else ''))
self.end_headers()
return None
self.path = '/index.html'
return super().send_head()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', type=int, default=5501)
args = parser.parse_args()
server = ThreadingHTTPServer(('localhost', args.port), Handler)
print(f'Animation app: http://localhost:{args.port}', flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()