From f2ff90d42c44747ad207e72c149cf48b5af26c24 Mon Sep 17 00:00:00 2001 From: Sam <34695753+SamEyeBam@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:13:52 +1200 Subject: [PATCH] BallOfLife --- docs/README.md | 28 ++++ docs/css/styles.css | 2 +- docs/index.html | 7 +- docs/js/controls/ControlFactory.js | 11 +- docs/js/controls/SceneUI.js | 33 ++++ docs/js/index.js | 3 + docs/js/shapes/BallOfLife.js | 252 +++++++++++++++++++++++++++++ docs/serve.py | 41 +++++ 8 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/js/shapes/BallOfLife.js create mode 100644 docs/serve.py diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..aee9904 --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/css/styles.css b/docs/css/styles.css index ae31616..ef592ef 100644 --- a/docs/css/styles.css +++ b/docs/css/styles.css @@ -979,4 +979,4 @@ canvas { .shape-picker-modal { width: 100%; } -} \ No newline at end of file +} diff --git a/docs/index.html b/docs/index.html index e9fe447..7e64723 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ mathfunk - + @@ -43,12 +43,13 @@ - + + @@ -69,4 +70,4 @@ - \ No newline at end of file + diff --git a/docs/js/controls/ControlFactory.js b/docs/js/controls/ControlFactory.js index 105a43d..29263ae 100644 --- a/docs/js/controls/ControlFactory.js +++ b/docs/js/controls/ControlFactory.js @@ -87,7 +87,7 @@ class ControlFactory { // Label showing property name const label = document.createElement('span'); label.className = 'control-label-inline'; - label.textContent = config.property; + label.textContent = config.label || config.property; // Value display (editable) const valueDisplay = document.createElement('span'); @@ -102,6 +102,7 @@ class ControlFactory { input.id = `el${config.property}`; input.min = config.min; input.max = config.max; + input.step = config.step ?? 1; input.value = instance[config.property] ?? config.defaultValue; // Store original config bounds for reference @@ -296,7 +297,7 @@ class ControlFactory { const label = document.createElement('span'); label.className = 'control-label-inline'; - label.textContent = config.property; + label.textContent = config.label || config.property; const input = document.createElement('input'); input.type = 'color'; @@ -351,7 +352,7 @@ class ControlFactory { const label = document.createElement('span'); label.className = 'control-label-inline'; - label.textContent = config.property; + label.textContent = config.label || config.property; const input = document.createElement('input'); input.type = 'checkbox'; @@ -393,7 +394,7 @@ class ControlFactory { const label = document.createElement('p'); label.className = 'control-label'; 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'); select.className = 'control control-dropdown'; @@ -410,7 +411,7 @@ class ControlFactory { const listener = (event) => { const newValue = event.target.value; instance[config.property] = newValue; - label.textContent = `${config.property}: ${newValue}`; + label.textContent = config.label || `${config.property}: ${newValue}`; if (config.callback) { config.callback(instance, newValue); } diff --git a/docs/js/controls/SceneUI.js b/docs/js/controls/SceneUI.js index 0e38a16..3783840 100644 --- a/docs/js/controls/SceneUI.js +++ b/docs/js/controls/SceneUI.js @@ -213,6 +213,39 @@ class SceneUI { if (index >= 0 && index < presets.length) { this.scene.importFromJSON(presets[index].data, this); 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.`); } } diff --git a/docs/js/index.js b/docs/js/index.js index 499d85a..38fbb3d 100644 --- a/docs/js/index.js +++ b/docs/js/index.js @@ -25,6 +25,9 @@ engine.setScene(scene); // Initialize Scene UI const sceneUI = new SceneUI(scene, 'layers-container'); +sceneUI.presetBasePath = new URL('../', document.currentScript.src).pathname; +sceneUI.loadPresetFromPath(); +window.addEventListener('popstate', () => sceneUI.loadPresetFromPath()); // ============================================ // Event Listeners diff --git a/docs/js/shapes/BallOfLife.js b/docs/js/shapes/BallOfLife.js new file mode 100644 index 0000000..9eecd85 --- /dev/null +++ b/docs/js/shapes/BallOfLife.js @@ -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); diff --git a/docs/serve.py b/docs/serve.py new file mode 100644 index 0000000..4160a43 --- /dev/null +++ b/docs/serve.py @@ -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()