From 20855586ba1e2d6d652b59f268a390618cb3b11c Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Tue, 20 Jan 2026 19:12:34 -0500 Subject: [PATCH 1/3] new chladni shape --- package-lock.json | 18 ++ package.json | 2 + src/common/geometry.js | 10 + src/features/shapes/chladni/Chladni.js | 239 +++++++++++++++++ src/features/shapes/chladni/README.md | 97 +++++++ src/features/shapes/chladni/config.js | 181 +++++++++++++ src/features/shapes/chladni/helpers.js | 29 ++ src/features/shapes/chladni/methods.js | 291 +++++++++++++++++++++ src/features/shapes/chladni/pathBuilder.js | 179 +++++++++++++ src/features/shapes/shapeFactory.js | 2 + 10 files changed, 1048 insertions(+) create mode 100644 src/features/shapes/chladni/Chladni.js create mode 100644 src/features/shapes/chladni/README.md create mode 100644 src/features/shapes/chladni/config.js create mode 100644 src/features/shapes/chladni/helpers.js create mode 100644 src/features/shapes/chladni/methods.js create mode 100644 src/features/shapes/chladni/pathBuilder.js diff --git a/package-lock.json b/package-lock.json index 0f60c8fc..bca0fe69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/sortable": "^10.0", "@reduxjs/toolkit": "^2.11.1", "array-move": "^4.0.0", + "bessel": "^1.0.2", "bootstrap": "^5.3.8", "buffer": "^6.0.3", "canvas": "^3.2.0", @@ -32,6 +33,7 @@ "liang-barsky": "^1.0.12", "lodash": "^4.17.21", "lru-cache": "^11.2.4", + "marching-squares": "^1.0.0", "mathjs": "^15.1.0", "opentype.js": "^1.3.4", "point-in-polygon": "^1.1.0", @@ -5711,6 +5713,14 @@ } ] }, + "node_modules/bessel": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz", + "integrity": "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -10713,6 +10723,14 @@ "tmpl": "1.0.5" } }, + "node_modules/marching-squares": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/marching-squares/-/marching-squares-1.0.0.tgz", + "integrity": "sha512-uJQBBYw76MEFFR90erke9oLZYYM5Nv8yp870p8jpzqxit6WeIQ7qh7sXEUdygMlcdTJ4hw6T41qB5oFjJAldHQ==", + "dependencies": { + "tslib": "^2.8.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 2ebe95e8..b9e0f2a9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@dnd-kit/sortable": "^10.0", "@reduxjs/toolkit": "^2.11.1", "array-move": "^4.0.0", + "bessel": "^1.0.2", "bootstrap": "^5.3.8", "buffer": "^6.0.3", "canvas": "^3.2.0", @@ -28,6 +29,7 @@ "liang-barsky": "^1.0.12", "lodash": "^4.17.21", "lru-cache": "^11.2.4", + "marching-squares": "^1.0.0", "mathjs": "^15.1.0", "opentype.js": "^1.3.4", "point-in-polygon": "^1.1.0", diff --git a/src/common/geometry.js b/src/common/geometry.js index 98ce19b9..6d9a8199 100644 --- a/src/common/geometry.js +++ b/src/common/geometry.js @@ -748,3 +748,13 @@ export const polygonArea = (vertices) => { return area / 2 } + +// Create a node with proximity-based key for graph operations +// Nodes within tolerance share the same key string +export const proximityNode = (x, y, tolerance = 1, precision = 2) => { + const sx = snapToGrid(x, tolerance) + const sy = snapToGrid(y, tolerance) + const key = `${sx.toFixed(precision)},${sy.toFixed(precision)}` + + return { x, y, toString: () => key } +} diff --git a/src/features/shapes/chladni/Chladni.js b/src/features/shapes/chladni/Chladni.js new file mode 100644 index 00000000..c4663eb4 --- /dev/null +++ b/src/features/shapes/chladni/Chladni.js @@ -0,0 +1,239 @@ +import Victor from "victor" +import Shape from "../Shape" +import { SUPERPOSITION } from "./config" +import { drawContours, drawBorder, buildPath } from "./pathBuilder" + +const options = { + chladniMethod: { + title: "Method", + type: "togglebutton", + choices: ["interference", "harmonic", "excitation"], + }, + chladniShape: { + title: "Shape", + type: "togglebutton", + choices: ["rectangular", "circular"], + }, + chladniM: { + title: "M", + min: (state) => (state.chladniShape === "circular" ? 0 : 1), + max: 10, + step: 1, + isVisible: (layer, state) => state.chladniMethod === "interference", + }, + chladniN: { + title: "N", + min: 1, + max: 10, + step: 1, + isVisible: (layer, state) => state.chladniMethod === "interference", + }, + chladniModes: { + title: "Complexity", + min: 1, + max: (state) => { + if ( + state.chladniMethod === "excitation" && + state.chladniShape === "circular" + ) + return 5 + return 10 + }, + step: 1, + isVisible: (layer, state) => + state.chladniMethod === "harmonic" || + state.chladniMethod === "excitation", + }, + chladniRadial: { + title: "Radial", + min: 1, + max: 10, + step: 1, + isVisible: (layer, state) => + state.chladniShape === "circular" && + !( + state.chladniMethod === "interference" && + state.chladniSuperposition === "rings" + ), + }, + chladniDecay: { + title: "Decay", + type: "slider", + min: 0, + max: 12, + isVisible: (layer, state) => state.chladniMethod === "harmonic", + }, + chladniFrequency: { + title: "Zoom", + min: 1, + max: (state) => { + if ( + state.chladniMethod === "harmonic" && + state.chladniShape === "circular" + ) + return 3 + return state.chladniModes > 4 ? 3 : 5 + }, + step: 0.25, + isVisible: (layer, state) => + state.chladniMethod === "harmonic" || + state.chladniMethod === "excitation", + }, + chladniExcitation: { + title: "Excitation", + type: "togglebutton", + choices: ["dome", "mosaic", "cell"], + isVisible: (layer, state) => + state.chladniMethod === "excitation" && + state.chladniShape === "rectangular", + }, + chladniContours: { + title: "Contours", + type: "slider", + min: 1, + max: 5, + step: 1, + }, + chladniSpread: { + title: "Spread", + type: "slider", + min: 1, + max: 9, + isVisible: (layer, state) => + state.chladniMethod === "excitation" && state.chladniModes >= 2, + }, + chladniPosition: { + title: "Position", + type: "slider", + min: 0, + max: 10, + isVisible: (layer, state) => + state.chladniMethod === "excitation" && + state.chladniShape === "rectangular" && + state.chladniExcitation === "cell", + }, + chladniBoundary: { + title: "Boundary", + type: "togglebutton", + choices: ["free", "fixed"], + isVisible: (layer, state) => + state.chladniMethod !== "excitation" && + !( + state.chladniMethod === "harmonic" && state.chladniShape === "circular" + ), + }, + chladniDomain: { + title: "Domain", + type: "togglebutton", + choices: ["centered", "tiled"], + isVisible: (layer, state) => + state.chladniMethod === "interference" && + state.chladniShape === "rectangular", + }, + chladniSuperposition: { + title: "Superposition", + type: "dropdown", + choices: Object.keys(SUPERPOSITION), + isVisible: (layer, state) => state.chladniMethod === "interference", + }, + chladniAmplitude: { + title: "Amplitude", + min: 1, + max: 16, + step: 1, + isVisible: (layer, state) => + state.chladniMethod === "interference" && state.chladniContours > 1, + }, +} + +export default class Chladni extends Shape { + constructor() { + super("chladni") + this.label = "Vibration" + this.link = "https://en.wikipedia.org/wiki/Chladni_figure" + this.linkText = "Wikipedia" + this.description = + "Chladni figures are nodal line patterns from vibrating plates. Sand collects along lines where amplitude is zero." + } + + getInitialState() { + return { + ...super.getInitialState(), + chladniMethod: "interference", + chladniShape: "rectangular", + chladniM: 2, + chladniN: 4, + chladniModes: 3, + chladniRadial: 3, + chladniDecay: 3, + chladniFrequency: 1, + chladniExcitation: "dome", + chladniSpread: 3, + chladniPosition: 5, + chladniSuperposition: "subtract", + chladniAmplitude: 4, + chladniBoundary: "fixed", + chladniDomain: "centered", + chladniContours: 3, + } + } + + getOptions() { + return options + } + + getVertices(state) { + const method = state.shape.chladniMethod + const shape = state.shape.chladniShape + const m = parseInt(state.shape.chladniM) + const n = parseInt(state.shape.chladniN) + const modes = parseInt(state.shape.chladniModes) + const radial = parseInt(state.shape.chladniRadial) + const decay = parseFloat(state.shape.chladniDecay) + const frequency = parseFloat(state.shape.chladniFrequency) + const excitation = + shape === "circular" ? "mosaic" : state.shape.chladniExcitation + const spread = parseFloat(state.shape.chladniSpread) + const position = parseFloat(state.shape.chladniPosition) + const superposition = state.shape.chladniSuperposition + const amplitude = parseFloat(state.shape.chladniAmplitude) + const boundary = state.shape.chladniBoundary + const domain = state.shape.chladniDomain + const contourLevels = parseInt(state.shape.chladniContours) + const scale = 50 + + // Handle degenerate case: m=n with "add" gives z=0 everywhere (interference only) + if (method === "interference" && m === n && superposition === "add") { + return [ + new Victor(-scale, -scale), + new Victor(scale, scale), + new Victor(0, 0), + new Victor(-scale, scale), + new Victor(scale, -scale), + ] + } + + const paths = drawContours({ + method, + shape, + m, + n, + modes, + radial, + decay, + frequency, + excitation, + spread, + position, + superposition, + amplitude, + boundary, + domain, + contourLevels, + }) + const allPaths = [...paths, drawBorder(shape)] + const vertices = buildPath(allPaths) + + return vertices.map((v) => new Victor(v.x * scale, v.y * scale)) + } +} diff --git a/src/features/shapes/chladni/README.md b/src/features/shapes/chladni/README.md new file mode 100644 index 00000000..c0d42d79 --- /dev/null +++ b/src/features/shapes/chladni/README.md @@ -0,0 +1,97 @@ +# Chladni Figures ("Vibrations") + +Chladni figures generate nodal line patterns from vibrating plates. When a plate vibrates, sand collects along lines where amplitude is zero, creating symmetric geometric patterns. This shape computes the vibration field mathematically and extracts contour lines. + +## Architecture + +The pipeline converts a 2D vibration equation into drawable paths: + +1. **Compute field**: Sample vibration amplitude on a grid using the selected method (interference, harmonic, or excitation). Resolution scales with complexity: `200 + complexity * 20`. + +2. **Extract contours**: The `marching-squares` library finds iso-lines at threshold values (z=0 for nodal lines, plus additional amplitude contours). + +3. **Build graph**: Contour segments become edges in a graph structure. + +4. **Eulerize**: Duplicate edges added to make all vertices even-degree (Chinese Postman). + +5. **Traverse**: An Eulerian trail visits every edge exactly once. This ensures a continuous path. + + +## Methods + +### Interference + +Two-mode superposition - the classic Chladni pattern. Two vibration modes interfere: + +``` +Rectangular: z = combine(trig(nπx)·trig(mπy), trig(mπx)·trig(nπy)) +Circular: z = combine(J_m(α·r)·trig(mθ), J_n(α·r)·trig(nθ)) +``` + +| Option | Description | +|--------|-------------| +| M, N | Mode numbers controlling pattern complexity | +| Superposition | How terms combine (see below) | +| Amplitude | Output scale (visible when contours > 1) | +| Boundary | free=cos (edges move), fixed=sin (edges clamped) | +| Domain | centered=[0,1], tiled=[-1,1] (rectangular only) | + +**Superposition modes** are defined in `config.js`. Each has: + +| Property | Purpose | +|----------|---------| +| `combine(t1, t2)` | How to merge the two terms | +| `terms(ctx)` | Optional custom term computation (used by `rings`) | +| `scale` | Amplitude adjustment per shape type | +| `contours` | Contour level adjustment per shape type | + +### Harmonic + +Multi-mode sum with decay weighting. Sums many modes together: + +``` +Rectangular: z = Σ Σ weight · trig(jπx)·trig(kπy) +Circular: z = Σ Σ weight · J_m(α·r)·cos(mθ) +``` + +| Option | Description | +|--------|-------------| +| Complexity | Number of modes to sum (1-10) | +| Decay | How quickly higher modes fall off (0-12) | +| Zoom | Pattern density / frequency | +| Boundary | free=cos, fixed=sin (rectangular only) | + +### Excitation + +Modal response to an initial displacement. This is our own creation - no reference implementations exist, so it's optimized for visual interest rather than strict physics. + +| Option | Description | +|--------|-------------| +| Complexity | Modes to sum; maps to fold symmetry for circular (1→2-fold, 2→4-fold, etc.) | +| Zoom | Pattern density / frequency | +| Spread | Mode decay rate (1-9) | +| Excitation | dome, mosaic, or cell (rectangular only) | +| Position | Excitation point location (cell only) | + +**Excitation types** (rectangular): + +| Type | Description | +|------|-------------| +| dome | Central bump; odd modes only | +| mosaic | Diagonal patterns (j ≠ k) | +| cell | Point excitation with position control | + +## Circular Plates and Bessel Functions + +Circular plates use Bessel functions of the first kind, J_n(x). The `bessel` npm package computes these. Nodal circles occur at the zeros of J_n, stored in `BESSEL_ZEROS[n][k]` (the k-th zero of J_n, 10 zeros for each of J_0 through J_10). + +| Option | Interference | Harmonic / Excitation | +|--------|--------------|----------------------| +| Radial | Selects which Bessel zero (acts as zoom) | Controls how many radial modes to sum | + +Circular always samples the [-1, 1] grid; points outside the unit circle return large values and are excluded from contours. + +## References + +- [marching-squares](https://github.com/smallsaucepan/marching-squares) - Contour extraction library +- [Chladni Figure (Wikipedia)](https://en.wikipedia.org/wiki/Chladni_figure) diff --git a/src/features/shapes/chladni/config.js b/src/features/shapes/chladni/config.js new file mode 100644 index 00000000..100b8f2a --- /dev/null +++ b/src/features/shapes/chladni/config.js @@ -0,0 +1,181 @@ +import BESSEL from "bessel" + +// Zeros of Bessel function J_n(x) for circular plate modes +// BESSEL_ZEROS[n][k-1] = k-th zero of J_n +export const BESSEL_ZEROS = [ + [ + 2.4048, 5.5201, 8.6537, 11.7915, 14.9309, 18.0711, 21.2116, 24.3525, + 27.4935, 30.6346, + ], // J_0 + [ + 3.8317, 7.0156, 10.1735, 13.3237, 16.4706, 19.6159, 22.7601, 25.9037, + 29.0468, 32.1897, + ], // J_1 + [ + 5.1356, 8.4172, 11.6198, 14.796, 17.9598, 21.117, 24.2701, 27.4206, 30.5692, + 33.7165, + ], // J_2 + [ + 6.3802, 9.761, 13.0152, 16.2235, 19.4094, 22.5827, 25.7482, 28.9084, + 32.0649, 35.2187, + ], // J_3 + [ + 7.5883, 11.0647, 14.3725, 17.616, 20.8269, 24.019, 27.1991, 30.371, 33.5371, + 36.699, + ], // J_4 + [ + 8.7715, 12.3386, 15.7002, 18.9801, 22.2178, 25.4303, 28.6266, 31.8117, + 34.9888, 38.1599, + ], // J_5 + [ + 9.9361, 13.5893, 17.0038, 20.3208, 23.5861, 26.8202, 30.0337, 33.233, + 36.422, 39.6032, + ], // J_6 + [ + 11.0864, 14.8213, 18.2876, 21.6415, 24.9349, 28.1912, 31.4228, 34.6371, + 37.8387, 41.0308, + ], // J_7 + [ + 12.2251, 16.0378, 19.5545, 22.9452, 26.2668, 29.5457, 32.7958, 36.0256, + 39.2404, 42.4439, + ], // J_8 + [ + 13.3543, 17.2412, 20.807, 24.2339, 27.5837, 30.8854, 34.1544, 37.4001, + 40.6286, 43.8438, + ], // J_9 + [ + 14.4755, 18.4335, 22.047, 25.5095, 28.8874, 32.2119, 35.4999, 38.7618, + 42.0042, 45.2316, + ], // J_10 +] + +// Excitation modes: contour and mode adjustments per excitation type +export const EXCITATION = { + cell: { + contours: { + rectangular: (value) => value + 2, + circular: (value) => value + 2, + }, + modes: { + rectangular: (value) => value + 1, + circular: (value) => value, + }, + }, + dome: { + contours: { + rectangular: (value) => value + 2, + circular: (value) => value + 2, + }, + }, + mosaic: { + modes: { + rectangular: (value) => value + 2, + circular: (value) => value + 2, + }, + contours: { + rectangular: (value) => value * 4, + circular: (value) => value + 2, + }, + }, +} + +// Superposition modes: terms() marshalls inputs, combine() merges them +// ctx contains: { term1, term2, m, n, x, y, r, theta, trig, shape } +export const SUPERPOSITION = { + add: { + combine: (t1, t2) => t1 + t2, + }, + beat: { + combine: (t1, t2) => (t1 + t2) * (t1 - t2), + scale: { + rectangular: 0.5, + circular: (value) => (value + 1) * 10, + }, + }, + difference: { + combine: (t1, t2) => Math.abs(t1 - t2), + scale: { + rectangular: (value) => value + 4, + circular: (value) => value + 4, + }, + contours: { + rectangular: (value) => value + 1, + circular: (value) => value + 1, + }, + }, + exclusion: { + combine: (t1, t2) => t1 + t2 - 2 * t1 * t2, + }, + grid: { + combine: (t1, t2) => t1 * t2, + scale: { rectangular: 2, circular: 24 }, + }, + hypot: { + combine: (t1, t2) => Math.hypot(t1, t2), + scale: { + rectangular: (value) => value + 2, + circular: (value) => value + 5, + }, + }, + max: { + combine: (t1, t2) => Math.max(t1, t2), + scale: { + rectangular: 1, + circular: (value) => value + 6, + }, + }, + min: { + combine: (t1, t2) => Math.min(t1, t2), + scale: { + rectangular: (value) => value + 2, + circular: (value) => value + 6, + }, + }, + modulate: { + combine: (t1, t2) => t1 * (1 + t2), + }, + overlay: { + combine: (t1, t2) => { + // normalize to [0,1], apply overlay, back to [-1,1] + const a = (t1 + 1) / 2 + const b = (t2 + 1) / 2 + const result = a <= 0.5 ? 2 * a * b : 1 - 2 * (1 - a) * (1 - b) + + return result * 2 - 1 + }, + scale: { + rectangular: (value) => value + 2, + circular: (value) => value + 4, + }, + }, + rings: { + terms: (ctx) => { + if (ctx.shape === "circular") { + const alpha1 = BESSEL_ZEROS[ctx.m][0] + const alpha2 = + BESSEL_ZEROS[ctx.m][Math.min(ctx.n, BESSEL_ZEROS[ctx.m].length) - 1] + + return [ + BESSEL.besselj(alpha1 * ctx.r, ctx.m) * ctx.trig(ctx.m * ctx.theta), + BESSEL.besselj(alpha2 * ctx.r, ctx.m) * ctx.trig(ctx.m * ctx.theta), + ] + } else { + return [ + ctx.trig(ctx.m * Math.PI * ctx.x) * ctx.trig(ctx.m * Math.PI * ctx.y), + ctx.trig(ctx.n * Math.PI * ctx.x) * ctx.trig(ctx.n * Math.PI * ctx.y), + ] + } + }, + combine: (t1, t2) => t1 - t2, + scale: { + rectangular: 1, + circular: (value) => value + 5, + }, + }, + screen: { + combine: (t1, t2) => t1 + t2 - t1 * t2, + }, + subtract: { + combine: (t1, t2) => t1 - t2, + }, +} diff --git a/src/features/shapes/chladni/helpers.js b/src/features/shapes/chladni/helpers.js new file mode 100644 index 00000000..610efad9 --- /dev/null +++ b/src/features/shapes/chladni/helpers.js @@ -0,0 +1,29 @@ +// Default values for superposition modes +export const defaultTerms = ({ term1, term2 }) => [term1, term2] +export const defaultScale = { rectangular: 1, circular: 2.5 } +export const defaultContours = { rectangular: 1, circular: 1 } + +// Get effective value for scale or contours, handling function vs number +// For functions: fn(value) / divisor +// For numbers: num * (value / divisor) +export function getEffectiveValue( + mode, + key, + shape, + value, + defaults, + divisor = 1, +) { + const v = (mode?.[key] || defaults)[shape] + + return typeof v === "function" ? v(value) / divisor : v * (value / divisor) +} + +// Convert cartesian to polar, returning null if outside unit circle +export function toPolar(x, y) { + const r = Math.sqrt(x * x + y * y) + + if (r > 1) return null + + return { r, theta: Math.atan2(y, x) } +} diff --git a/src/features/shapes/chladni/methods.js b/src/features/shapes/chladni/methods.js new file mode 100644 index 00000000..a0a7035b --- /dev/null +++ b/src/features/shapes/chladni/methods.js @@ -0,0 +1,291 @@ +import BESSEL from "bessel" +import { BESSEL_ZEROS, SUPERPOSITION, EXCITATION } from "./config" +import { + defaultTerms, + defaultScale, + getEffectiveValue, + toPolar, +} from "./helpers" + +// Rectangular plate interference pattern +// Domain: [0, 1] or [-1, 1] +export function interferenceRectangular(x, y, opts) { + const { m, n, superposition, amplitude, boundary } = opts + const trig = boundary === "free" ? Math.cos : Math.sin + const mode = SUPERPOSITION[superposition] + const term1 = trig(n * Math.PI * x) * trig(m * Math.PI * y) + const term2 = trig(m * Math.PI * x) * trig(n * Math.PI * y) + const ctx = { term1, term2, m, n, x, y, trig, shape: "rectangular" } + const [t1, t2] = (mode.terms || defaultTerms)(ctx) + const scale = getEffectiveValue( + mode, + "scale", + "rectangular", + amplitude, + defaultScale, + 4, + ) + + return mode.combine(t1, t2) * scale +} + +// Circular plate interference pattern using Bessel functions +// m, n = angular mode numbers, radial = which Bessel zero to use +// Domain: x,y in [-1, 1], circular boundary at r=1 +export function interferenceCircular(x, y, opts) { + const { m, n, radial, superposition, amplitude, boundary } = opts + const polar = toPolar(x, y) + + if (!polar) return 1000 + + const { r, theta } = polar + const trig = boundary === "free" ? Math.cos : Math.sin + const mode = SUPERPOSITION[superposition] + + // Default terms: two different angular modes, same radial zero + const alphaM = BESSEL_ZEROS[m][radial - 1] + const alphaN = BESSEL_ZEROS[n][radial - 1] + const term1 = BESSEL.besselj(alphaM * r, m) * trig(m * theta) + const term2 = BESSEL.besselj(alphaN * r, n) * trig(n * theta) + const ctx = { term1, term2, m, n, r, theta, trig, shape: "circular" } + const [t1, t2] = (mode.terms || defaultTerms)(ctx) + const scale = getEffectiveValue( + mode, + "scale", + "circular", + amplitude, + defaultScale, + 4, + ) + + return mode.combine(t1, t2) * scale +} + +// Harmonic rectangular: sum many modes +// Uses full [-1,1] range internally so sin/cos oscillates through +/- +// frequency scales all harmonics (zoom), modes controls complexity +export function harmonicRectangular(x, y, opts) { + const { modes, decay, frequency, boundary } = opts + const trig = boundary === "free" ? Math.cos : Math.sin + const scaledFreq = frequency * 1.5 - 1 + const decayExp = (decay + 1) / 8 + + // Map to [-1,1] so trig functions cross zero + const cx = x * 2 - 1 + const cy = y * 2 - 1 + let sum = 0 + + for (let j = 1; j <= modes; j++) { + for (let k = 1; k <= modes; k++) { + const weight = 1 / Math.pow(j + k, decayExp) + + sum += + weight * + trig(j * Math.PI * scaledFreq * cx) * + trig(k * Math.PI * scaledFreq * cy) + } + } + + return sum +} + +// Harmonic circular: sum Bessel modes with decay +// z = Σ Σ weight · J_m(α_mn·r)·cos(mθ) for angular modes m and radial modes n +// modes controls angular complexity, radial controls radial complexity +export function harmonicCircular(x, y, opts) { + let { modes, radial, decay, frequency } = opts + const polar = toPolar(x, y) + + if (!polar) return 1000 + + const { r, theta } = polar + const scaledFreq = frequency * 1.5 + const decayExp = (decay + 1) / 16 + let sum = 0 + + modes = modes + 1 // boost for circular + + // Loop over angular modes (m) and radial modes (n) + // m=0 gives concentric circles, m>0 adds angular variation + // n selects which Bessel zero (more zeros = more radial rings) + for (let m = 0; m < modes && m < BESSEL_ZEROS.length; m++) { + for (let n = 1; n <= radial && n <= BESSEL_ZEROS[m].length; n++) { + const alpha = BESSEL_ZEROS[m][n - 1] + const weight = 1 / Math.pow(m + n, decayExp) + const radialTerm = BESSEL.besselj(alpha * r * scaledFreq, m) + const angular = Math.cos(m * theta) + + sum += weight * radialTerm * angular + } + } + + return sum +} + +// Excitation rectangular dome: separate function for odd-mode iteration +// Complexity maps directly to odd modes: 1→1, 2→3, 3→5, etc. +function excitationRectangularDome(x, y, spread, modes, frequency) { + const freq = frequency * 1.5 - 0.5 + let sum = 0 + + for (let jIdx = 0; jIdx < modes; jIdx++) { + const j = jIdx * 2 + 1 // odd: 1, 3, 5, ... + for (let kIdx = 0; kIdx < modes; kIdx++) { + const k = kIdx * 2 + 1 // odd: 1, 3, 5, ... + // Power-law decay: (1,1) normalized to 1, spread controls decay rate + // Higher spread = slower decay = more high-mode contribution + const modeEnergy = j * j + k * k + const weight = Math.pow(2 / modeEnergy, 1 / spread) + + sum += + weight * + Math.sin(j * Math.PI * freq * x) * + Math.sin(k * Math.PI * freq * y) + } + } + + return sum +} + +// Excitation rectangular: modal response to initial displacement +// Each excitation type has different modal weights +// Uses [0,1] domain matching interference "centered" mode +export function excitationRectangular(x, y, opts) { + const { excitation, spread, position, frequency } = opts + let { modes } = opts + const modesFn = EXCITATION[excitation]?.modes?.rectangular + + modes = modesFn ? modesFn(modes) : modes + + if (excitation === "dome") { + return excitationRectangularDome(x, y, spread, modes, frequency) + } + + const spreadFactor = spread / 5 + const freq = frequency * 1.5 - 0.5 + let sum = 0 + + for (let j = 1; j <= modes; j++) { + for (let k = 1; k <= modes; k++) { + let weight + + switch (excitation) { + case "cell": { + // Point excitation sweeping across plate + // Position 0-10 maps to x₀ ∈ [0.1, 0.9] (avoiding edges where modes vanish) + const x0 = 0.1 + position * 0.08 + const y0 = 0.55 // slight offset from center to break symmetry + const posWeight = + Math.sin(j * Math.PI * x0) * Math.sin(k * Math.PI * y0) + // Gentler decay exponent, amplitude boost to compensate + const decayFactor = Math.pow(spreadFactor, (j + k) / 8) + + weight = posWeight * decayFactor * 2 + break + } + + case "mosaic": + // Mosaic modes (j ≠ k) for diagonal patterns + if (j === k) { + weight = 0 + } else { + weight = spreadFactor / (j + k) + } + break + + default: + weight = 1 / (j + k) + } + + sum += + weight * + Math.sin(j * Math.PI * freq * x) * + Math.sin(k * Math.PI * freq * y) + } + } + + return sum +} + +// Excitation circular: modal response with Bessel functions +// Loops over angular modes (m) and radial modes (n) +export function excitationCircular(x, y, opts) { + const { excitation, spread, position, modes, radial, frequency } = opts + const polar = toPolar(x, y) + + if (!polar) return 1000 + + const { r, theta } = polar + const spreadFactor = spread / 5 + const freq = frequency * 2 - 1 + let sum = 0 + const loopModes = excitation === "mosaic" ? modes * 2 + 1 : modes + + for (let m = 0; m < loopModes && m < BESSEL_ZEROS.length; m++) { + for (let n = 1; n <= radial && n <= BESSEL_ZEROS[m].length; n++) { + const alpha = BESSEL_ZEROS[m][n - 1] + let weight + + switch (excitation) { + case "cell": { + // Angular excitation - position controls angle around the plate + // Position 0-10 maps to θ₀ ∈ [0, 2π] + const theta0 = (position * Math.PI) / 5 + const r0 = 0.6 // fixed radius for excitation point + + weight = + BESSEL.besselj(alpha * r0, m) * + Math.cos(m * theta0) * + Math.pow(spreadFactor, (m + n) / 16) + break + } + + case "dome": + // Central dome: only m=0 (radially symmetric) + weight = m === 0 ? (spreadFactor * 2) / n : 0 + break + + case "mosaic": { + // Mosaic: modes controls fold symmetry (2-fold, 4-fold, 6-fold...) + // modes=1 → m=2, modes=2 → m=4, modes=3 → m=6, etc. + const dominantM = modes * 2 + + if (m === dominantM) { + weight = (spreadFactor * 2) / n + } else if (m === 0) { + weight = (spreadFactor * 0.3) / n + } else { + weight = 0 + } + break + } + + default: + weight = 1 / (m + n) + } + + const radialTerm = BESSEL.besselj(alpha * r * freq, m) + const angular = Math.cos(m * theta) + + sum += weight * radialTerm * angular + } + } + + return sum +} + +// Method dispatch: METHOD[method][shape](x, y, opts) +export const METHOD = { + interference: { + circular: interferenceCircular, + rectangular: interferenceRectangular, + }, + harmonic: { + circular: harmonicCircular, + rectangular: harmonicRectangular, + }, + excitation: { + circular: excitationCircular, + rectangular: excitationRectangular, + }, +} diff --git a/src/features/shapes/chladni/pathBuilder.js b/src/features/shapes/chladni/pathBuilder.js new file mode 100644 index 00000000..13a6bc76 --- /dev/null +++ b/src/features/shapes/chladni/pathBuilder.js @@ -0,0 +1,179 @@ +import Victor from "victor" +import { isoLines } from "marching-squares" +import Graph, { getEulerianTrail } from "@/common/Graph" +import { + proximityNode, + cloneVertex, + cloneVertices, + circle, +} from "@/common/geometry" +import { SUPERPOSITION, EXCITATION } from "./config" +import { METHOD } from "./methods" +import { defaultContours, getEffectiveValue } from "./helpers" + +// Grid resolution scales with mode numbers for detail +const BASE_RESOLUTION = 200 + +// Convert grid index to domain coordinate +// "centered" uses [0, 1], "tiled" uses [-1, 1] +export function gridToDomain(i, resolution, domain) { + const t = i / resolution + + return domain === "centered" ? t : t * 2 - 1 +} + +// Generate threshold values for contour levels +// Level 1: just nodal line (z=0) +// Level 2+: add amplitude contours +export function getThresholds(levels) { + if (levels === 1) return [0] + + const thresholds = [] + const step = 1.5 / levels + + for (let i = -levels + 1; i < levels; i++) { + thresholds.push(i * step) + } + + return thresholds +} + +// Draw contours using marching-squares library +export function drawContours(opts) { + const { method, shape, modes, m, n, contourLevels, domain } = opts + const computeField = METHOD[method][shape] + + // Resolution scales with complexity + const complexity = + method === "harmonic" ? modes : method === "excitation" ? 5 : Math.max(m, n) + const resolution = BASE_RESOLUTION + complexity * 20 + const data = [] + + // Circular always samples [-1,1] grid (circle centered at origin) + // but applies domain transform in the formula + // Rectangular uses domain to control grid sampling directly + const effectiveDomain = shape === "circular" ? "tiled" : domain + + for (let j = 0; j <= resolution; j++) { + const row = [] + const y = gridToDomain(j, resolution, effectiveDomain) + + for (let i = 0; i <= resolution; i++) { + const x = gridToDomain(i, resolution, effectiveDomain) + + row.push(computeField(x, y, opts)) + } + data.push(row) + } + + const mode = + method === "excitation" + ? EXCITATION[opts.excitation] + : SUPERPOSITION[opts.superposition] + const effectiveLevels = getEffectiveValue( + mode, + "contours", + shape, + contourLevels, + defaultContours, + ) + const thresholds = getThresholds(effectiveLevels) + const allContours = isoLines(data, thresholds) + const paths = allContours.flatMap((contours) => + contours.map((path) => + path.map(([col, row]) => ({ + x: (col / resolution) * 2 - 1, + y: (row / resolution) * 2 - 1, + })), + ), + ) + + return paths +} + +// Draw border shape +export function drawBorder(type) { + if (type === "circular") { + return circle(1) + } + + return [ + { x: -1, y: -1 }, + { x: 1, y: -1 }, + { x: 1, y: 1 }, + { x: -1, y: 1 }, + { x: -1, y: -1 }, + ] +} + +// Build graph from paths for Eulerian trail +export function buildPathGraph(paths, tolerance = 0.01, precision = 4) { + const graph = new Graph() + const CLOSED_TOLERANCE = tolerance * 2 + + for (const path of paths) { + if (path.length < 2) continue + + for (const pt of path) { + graph.addNode(proximityNode(pt.x, pt.y, tolerance, precision)) + } + + for (let i = 0; i < path.length - 1; i++) { + const pt1 = path[i] + const pt2 = path[i + 1] + const node1 = proximityNode(pt1.x, pt1.y, tolerance, precision) + const node2 = proximityNode(pt2.x, pt2.y, tolerance, precision) + + if (node1.toString() !== node2.toString()) { + graph.addEdge(node1, node2) + } + } + + // For closed paths, add the closing edge + const startPt = path[0] + const endPt = path[path.length - 1] + const dist = Math.hypot(endPt.x - startPt.x, endPt.y - startPt.y) + + if (dist < CLOSED_TOLERANCE) { + const node1 = proximityNode(endPt.x, endPt.y, tolerance, precision) + const node2 = proximityNode(startPt.x, startPt.y, tolerance, precision) + + if (node1.toString() !== node2.toString()) { + graph.addEdge(node1, node2) + } + } + } + + return graph +} + +// Build continuous path from multiple contour paths +export function buildPath(paths) { + if (paths.length === 0) { + return [new Victor(0, 0)] + } + + const victorPaths = paths.map((path) => cloneVertices(path)) + + if (victorPaths.length === 1) { + return victorPaths[0] + } + + const tolerance = 0.01 + const graph = buildPathGraph(victorPaths, tolerance) + + graph.connectComponents() + + const trail = getEulerianTrail(graph) + const vertices = [] + + for (const nodeKey of trail) { + const node = graph.nodeMap[nodeKey] + + if (node) { + vertices.push(cloneVertex(node)) + } + } + + return vertices.length > 0 ? vertices : [new Victor(0, 0)] +} diff --git a/src/features/shapes/shapeFactory.js b/src/features/shapes/shapeFactory.js index b9b1297f..bfd13303 100644 --- a/src/features/shapes/shapeFactory.js +++ b/src/features/shapes/shapeFactory.js @@ -1,5 +1,6 @@ /* global localStorage */ +import Chladni from "./chladni/Chladni" import Circle from "./Circle" import Epicycloid from "./Epicycloid" import FancyText from "./FancyText" @@ -45,6 +46,7 @@ export const shapeFactory = { circlePacker: CirclePacker, wiper: Wiper, spaceFiller: SpaceFiller, + chladni: Chladni, noise_wave: NoiseWave, fileImport: LayerImport, imageImport: ImageImport, From 75a8e77bf628f7fa5c704516e1e1a08e27c6e602 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Fri, 23 Jan 2026 06:18:26 -0500 Subject: [PATCH 2/3] more cleanup; guard unavailable effect --- src/common/geometry.js | 6 ++++++ src/features/effects/Warp.js | 21 +++++--------------- src/features/effects/effectFactory.js | 5 ++++- src/features/shapes/chladni/helpers.js | 11 ++--------- src/features/shapes/chladni/methods.js | 27 +++++++++++--------------- 5 files changed, 28 insertions(+), 42 deletions(-) diff --git a/src/common/geometry.js b/src/common/geometry.js index 6d9a8199..88aa41fc 100644 --- a/src/common/geometry.js +++ b/src/common/geometry.js @@ -113,6 +113,12 @@ export const angle = (point) => { return Math.atan2(point.y, point.x) } +// Convert cartesian to polar coordinates +export const toPolar = (x, y) => ({ + r: Math.sqrt(x * x + y * y), + theta: Math.atan2(y, x), +}) + // returns whether a point is on the segment defined by start and end export const onSegment = (start, end, point) => { return ( diff --git a/src/features/effects/Warp.js b/src/features/effects/Warp.js index 9f938cb9..e2f9cd9e 100644 --- a/src/features/effects/Warp.js +++ b/src/features/effects/Warp.js @@ -3,7 +3,7 @@ import Victor from "victor" import Effect from "./Effect" import { effectOptions } from "./EffectLayer" -import { subsample, circle } from "@/common/geometry" +import { subsample, circle, toPolar } from "@/common/geometry" import { evaluate } from "mathjs" const options = { @@ -161,21 +161,10 @@ export default class Warp extends Effect { return vertices.map((vertex) => { const originalx = vertex.x - effect.x const originaly = vertex.y - effect.y - const theta = Math.atan2(originaly, originalx) - const x = - originalx + - scale * - Math.cos(theta) * - Math.cos( - Math.sqrt(originalx * originalx + originaly * originaly) / periodx, - ) - const y = - originaly + - scale * - Math.sin(theta) * - Math.cos( - Math.sqrt(originalx * originalx + originaly * originaly) / periody, - ) + const { r, theta } = toPolar(originalx, originaly) + const x = originalx + scale * Math.cos(theta) * Math.cos(r / periodx) + const y = originaly + scale * Math.sin(theta) * Math.cos(r / periody) + return new Victor(x + effect.x, y + effect.y) }) } diff --git a/src/features/effects/effectFactory.js b/src/features/effects/effectFactory.js index 811abb96..89f74d4d 100644 --- a/src/features/effects/effectFactory.js +++ b/src/features/effects/effectFactory.js @@ -29,7 +29,10 @@ export const getEffect = (type, ...args) => { } export const getDefaultEffectType = () => { - return localStorage.getItem("defaultEffect") || "mask" + const effect = localStorage.getItem("defaultEffect") + + // validate that the effect type exists (it may not if switching branches) + return effect && effectFactory[effect] ? effect : "mask" } export const getDefaultEffect = () => { diff --git a/src/features/shapes/chladni/helpers.js b/src/features/shapes/chladni/helpers.js index 610efad9..a0840e83 100644 --- a/src/features/shapes/chladni/helpers.js +++ b/src/features/shapes/chladni/helpers.js @@ -1,3 +1,5 @@ +export const getTrig = (boundary) => (boundary === "free" ? Math.cos : Math.sin) + // Default values for superposition modes export const defaultTerms = ({ term1, term2 }) => [term1, term2] export const defaultScale = { rectangular: 1, circular: 2.5 } @@ -18,12 +20,3 @@ export function getEffectiveValue( return typeof v === "function" ? v(value) / divisor : v * (value / divisor) } - -// Convert cartesian to polar, returning null if outside unit circle -export function toPolar(x, y) { - const r = Math.sqrt(x * x + y * y) - - if (r > 1) return null - - return { r, theta: Math.atan2(y, x) } -} diff --git a/src/features/shapes/chladni/methods.js b/src/features/shapes/chladni/methods.js index a0a7035b..ff94676e 100644 --- a/src/features/shapes/chladni/methods.js +++ b/src/features/shapes/chladni/methods.js @@ -1,17 +1,18 @@ import BESSEL from "bessel" import { BESSEL_ZEROS, SUPERPOSITION, EXCITATION } from "./config" +import { toPolar } from "@/common/geometry" import { defaultTerms, defaultScale, getEffectiveValue, - toPolar, + getTrig, } from "./helpers" // Rectangular plate interference pattern // Domain: [0, 1] or [-1, 1] export function interferenceRectangular(x, y, opts) { const { m, n, superposition, amplitude, boundary } = opts - const trig = boundary === "free" ? Math.cos : Math.sin + const trig = getTrig(boundary) const mode = SUPERPOSITION[superposition] const term1 = trig(n * Math.PI * x) * trig(m * Math.PI * y) const term2 = trig(m * Math.PI * x) * trig(n * Math.PI * y) @@ -34,12 +35,10 @@ export function interferenceRectangular(x, y, opts) { // Domain: x,y in [-1, 1], circular boundary at r=1 export function interferenceCircular(x, y, opts) { const { m, n, radial, superposition, amplitude, boundary } = opts - const polar = toPolar(x, y) + const { r, theta } = toPolar(x, y) - if (!polar) return 1000 - - const { r, theta } = polar - const trig = boundary === "free" ? Math.cos : Math.sin + if (r > 1) return 1000 + const trig = getTrig(boundary) const mode = SUPERPOSITION[superposition] // Default terms: two different angular modes, same radial zero @@ -66,7 +65,7 @@ export function interferenceCircular(x, y, opts) { // frequency scales all harmonics (zoom), modes controls complexity export function harmonicRectangular(x, y, opts) { const { modes, decay, frequency, boundary } = opts - const trig = boundary === "free" ? Math.cos : Math.sin + const trig = getTrig(boundary) const scaledFreq = frequency * 1.5 - 1 const decayExp = (decay + 1) / 8 @@ -94,11 +93,9 @@ export function harmonicRectangular(x, y, opts) { // modes controls angular complexity, radial controls radial complexity export function harmonicCircular(x, y, opts) { let { modes, radial, decay, frequency } = opts - const polar = toPolar(x, y) - - if (!polar) return 1000 + const { r, theta } = toPolar(x, y) - const { r, theta } = polar + if (r > 1) return 1000 const scaledFreq = frequency * 1.5 const decayExp = (decay + 1) / 16 let sum = 0 @@ -211,11 +208,9 @@ export function excitationRectangular(x, y, opts) { // Loops over angular modes (m) and radial modes (n) export function excitationCircular(x, y, opts) { const { excitation, spread, position, modes, radial, frequency } = opts - const polar = toPolar(x, y) - - if (!polar) return 1000 + const { r, theta } = toPolar(x, y) - const { r, theta } = polar + if (r > 1) return 1000 const spreadFactor = spread / 5 const freq = frequency * 2 - 1 let sum = 0 From bd1866f8f18c1fe33ea26397445a3397825d46a2 Mon Sep 17 00:00:00 2001 From: Bob Carmichael Date: Fri, 23 Jan 2026 06:29:52 -0500 Subject: [PATCH 3/3] missed a few translations --- src/i18n/locales/en.json | 2 ++ src/i18n/locales/zh.json | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 193c4ce1..d23555db 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1,5 +1,6 @@ { "about.tagline": "create patterns for robots that draw in sand with ball bearings", + "description.chladni": "Chladni figures are nodal line patterns from vibrating plates. Sand collects along lines where amplitude is zero.", "description.circlePacker": "Circle packing is an arrangement of circles of different sizes such that no overlapping occurs and no circle can be enlarged without creating an overlap.", "description.epicycloid": "A clover shape is an epicycloid. Imagine two circles, with the outer circle rolling around the inside one. The shape traced by a point on the outer circle as it rolls is called an epicycloid.", "description.fractalSpirograph": "A fractal spirograph is generated by a series of circles that rotate around one another. The pattern is created by tracing a point that rides along the outermost circle as it rolls.", @@ -16,6 +17,7 @@ "description.voronoi": "A Voronoi diagram divides a space into regions based on a set of seed points. Each region contains all the points that are closer to its seed point than to any other seed point.", "export.wikiNote": "See the <0>wiki for details on program export variables.", "layer.seeMoreInfo": "See <0>{{linkText}} for more information.", + "linkText.chladni": "Wikipedia", "linkText.circlePacker": "Wikipedia", "linkText.epicycloid": "Wolfram Mathworld", "linkText.fractalSpirograph": "this blog post", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9f0bd101..4f0a6469 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1,5 +1,6 @@ { "about.tagline": "为使用滚珠在沙子中绘画的机器人创建图案", + "description.chladni": "克拉尼图形是振动板的节线图案。沙子聚集在振幅为零的线上。", "description.circlePacker": "圆形包装是不同大小圆形的排列,使得不会发生重叠,且在不产生重叠的情况下无法放大任何圆形。", "description.epicycloid": "三叶草形状是外摆线。想象两个圆,外圆绕着内圆滚动。外圆上的点在滚动过程中形成的轨迹称为外摆线。", "description.fractalSpirograph": "分形螺旋图由一系列相互旋转的圆生成。图案是通过跟踪沿最外圆滚动的点创建的。", @@ -16,6 +17,7 @@ "description.voronoi": "沃罗诺伊图根据一组种子点将空间划分为区域。每个区域包含所有比其他种子点更接近其种子点的点。", "export.wikiNote": "有关程序导出变量的详细信息,请参阅<0>wiki。", "layer.seeMoreInfo": "查看<0>{{linkText}}了解更多信息。", + "linkText.chladni": "维基百科", "linkText.circlePacker": "维基百科", "linkText.epicycloid": "Wolfram 数学世界", "linkText.fractalSpirograph": "这篇博客文章", @@ -262,6 +264,7 @@ "upper left": "左上", "upper right": "右上", "Velocity": "速度", + "Vibration": "振动", "Voronoi": "沃罗诺伊图", "W": "宽", "Warp": "扭曲",