Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/common/geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -748,3 +754,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 }
}
21 changes: 5 additions & 16 deletions src/features/effects/Warp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
})
}
Expand Down
5 changes: 4 additions & 1 deletion src/features/effects/effectFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
239 changes: 239 additions & 0 deletions src/features/shapes/chladni/Chladni.js
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading