Fixes, still not quite there but does produce a butterfly
This commit is contained in:
parent
255844589f
commit
7812074151
3 changed files with 38063 additions and 29415 deletions
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -23,18 +23,24 @@ The 3D surface is swept over the control parameter space (a, b), with x (the sta
|
||||||
|
|
||||||
### Butterfly Catastrophe
|
### Butterfly Catastrophe
|
||||||
|
|
||||||
- **Potential:** V(x) = x⁶ + ax⁴ + bx³ (with c=0, d=0 fixed)
|
- **Potential:** V(x) = x⁶ + ax⁴ + cx² + dx (a = −3 fixed, b = 0)
|
||||||
- **Equilibrium condition:** dV/dx = 6x⁵ + 4ax³ + 3bx² = 0
|
- **Equilibrium condition:** dV/dx = 6x⁵ − 12x³ + 2cx + d = 0
|
||||||
- **Control space:** (a, b) swept over a 2D grid
|
- **Control space:** (c, d) swept over a 2D grid
|
||||||
- **State space:** up to 5 real roots x at any given (a, b)
|
- **State space:** up to 5 real roots x at any given (c, d)
|
||||||
- **Characteristic feature:** nested "butterfly wing" fold structure — more complex than the cusp, with additional inner fold lobes
|
- **Characteristic feature:** nested "butterfly wing" fold structure — a self-intersecting bifurcation curve in the (c, d) plane enclosing a 5-root "pocket" (c∈[0,3], d≈0), surrounded by a 3-root wing region, with a single-root region outside
|
||||||
|
|
||||||
|
> **Why not vary (a, b) with c=d=0?** With c=d=0, the equilibrium equation factors as
|
||||||
|
> x²(6x³ + 4ax + 3b) = 0 — x=0 is always a double root and the remaining roots come
|
||||||
|
> from a cubic, which is structurally identical to the **cusp** catastrophe. The butterfly
|
||||||
|
> structure only appears when d ≠ 0 generically, which requires d (or an equivalent odd
|
||||||
|
> perturbation) to be varied as a control parameter.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `butterfly_catastrophe.py` — generates the butterfly surface STL
|
- `butterfly_catastrophe.py` — generates the butterfly surface STL
|
||||||
- `butterfly_catastrophe.stl` — ready-to-slice output (ASCII STL, ~7,300 triangles)
|
- `butterfly_catastrophe.stl` — ready-to-slice output (ASCII STL)
|
||||||
- `CLAUDE.md` — this file
|
- `CLAUDE.md` — this file
|
||||||
|
|
||||||
> A cusp catastrophe script also exists and was the starting point for this project.
|
> A cusp catastrophe script also exists and was the starting point for this project.
|
||||||
|
|
@ -63,10 +69,11 @@ Output: `butterfly_catastrophe.stl`
|
||||||
|
|
||||||
| Parameter | Default | Effect |
|
| Parameter | Default | Effect |
|
||||||
|-----------|---------|--------|
|
|-----------|---------|--------|
|
||||||
| `grid` | `40` | Resolution of the (a,b) control grid — increase for finer mesh |
|
| `GRID` | `50` | Resolution of the (c,d) control grid — increase to 80–100 for final print |
|
||||||
| `a_vals` range | `(-2.5, 1.2)` | Range of control parameter a |
|
| `C_RANGE` | `(-2.0, 7.0)` | Range of control parameter c |
|
||||||
| `b_vals` range | `(-2.5, 2.5)` | Range of control parameter b |
|
| `D_RANGE` | `(-6.0, 6.0)` | Range of control parameter d |
|
||||||
| `x_range` | `2.2` | Search window for equilibrium roots |
|
| `X_RANGE` | `2.5` | Search window for equilibrium roots |
|
||||||
|
| `MAX_EDGE_DZ` | `0.6` | Z-jump threshold for rejecting branch-mismatch triangles at fold edges |
|
||||||
|
|
||||||
For a final high-quality print, increase `grid` to `80`–`100`. The default of `40` is optimised for STL viewer compatibility.
|
For a final high-quality print, increase `grid` to `80`–`100`. The default of `40` is optimised for STL viewer compatibility.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,140 @@
|
||||||
"""
|
"""
|
||||||
Butterfly Catastrophe Surface - ASCII STL Generator
|
Butterfly Catastrophe Surface — ASCII STL Generator
|
||||||
Potential: V(x) = x^6 + a*x^4 + b*x^3
|
|
||||||
Equilibrium surface: dV/dx = 6x^5 + 4a*x^3 + 3b*x^2 = 0
|
Potential: V(x) = x^6 + a*x^4 + c*x^2 + d*x (a = -3 fixed, b = 0)
|
||||||
|
Equilibrium: dV/dx = 6x^5 - 12x^3 + 2c*x + d = 0
|
||||||
|
|
||||||
|
Control parameters (print base): c (horizontal), d (depth)
|
||||||
|
State variable (print height): x
|
||||||
|
|
||||||
|
With a = -3, the bifurcation set in the (c, d) plane forms the characteristic
|
||||||
|
butterfly shape: a self-intersecting loop passing through (c=3, d=0), enclosing
|
||||||
|
a region with 5 equilibria ("butterfly pocket"), surrounded by a 3-root region
|
||||||
|
with outer fold wings, and a single-root region outside. This is structurally
|
||||||
|
different from the cusp catastrophe and cannot be seen with c = d = 0 fixed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
A_FIXED = -3.0 # butterfly unfolding parameter (must be negative)
|
||||||
# 1. Find equilibrium roots of dV/dx = 0
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def dV(x, a, b):
|
# ── Tuning ──────────────────────────────────────────────────────────────────
|
||||||
return 6*x**5 + 4*a*x**3 + 3*b*x**2
|
GRID = 50 # control-space resolution — increase to 80–100 for print
|
||||||
|
C_RANGE = (-2.0, 7.0) # range of control parameter c
|
||||||
|
D_RANGE = (-6.0, 6.0) # range of control parameter d
|
||||||
|
X_RANGE = 2.5 # half-width of root search window
|
||||||
|
MAX_EDGE_DZ = 0.6 # max z-jump per quad edge (filters branch mismatches)
|
||||||
|
|
||||||
def d2V(x, a, b):
|
# ── 1. Root finding ──────────────────────────────────────────────────────────
|
||||||
return 30*x**4 + 12*a*x**2 + 6*b*x
|
|
||||||
|
|
||||||
def find_roots(a, b, n_starts=40, x_range=2.2):
|
def dV(x, c, d):
|
||||||
xs = np.linspace(-x_range, x_range, n_starts)
|
return 6*x**5 + 4*A_FIXED*x**3 + 2*c*x + d
|
||||||
|
|
||||||
|
def d2V(x, c, d):
|
||||||
|
return 30*x**4 + 12*A_FIXED*x**2 + 2*c
|
||||||
|
|
||||||
|
def find_roots(c, d, n_starts=80):
|
||||||
|
"""Return sorted real roots of dV/dx = 0 for the given (c, d)."""
|
||||||
|
xs = np.linspace(-X_RANGE, X_RANGE, n_starts)
|
||||||
roots = []
|
roots = []
|
||||||
for x0 in xs:
|
for x0 in xs:
|
||||||
x = float(x0)
|
x = float(x0)
|
||||||
for _ in range(100):
|
for _ in range(200):
|
||||||
fx = dV(x, a, b)
|
fx = dV(x, c, d)
|
||||||
dfx = d2V(x, a, b)
|
if abs(fx) < 1e-12:
|
||||||
|
break
|
||||||
|
dfx = d2V(x, c, d)
|
||||||
if abs(dfx) < 1e-14:
|
if abs(dfx) < 1e-14:
|
||||||
break
|
break
|
||||||
step = fx / dfx
|
step = fx / dfx
|
||||||
x -= step
|
x -= step
|
||||||
|
if abs(x) > 2.0 * X_RANGE: # diverged — abandon
|
||||||
|
break
|
||||||
if abs(step) < 1e-10:
|
if abs(step) < 1e-10:
|
||||||
break
|
break
|
||||||
if abs(dV(x, a, b)) < 1e-7 and abs(x) <= x_range + 0.05:
|
if abs(dV(x, c, d)) < 1e-7 and abs(x) <= X_RANGE + 0.15:
|
||||||
if not any(abs(x - r) < 1e-4 for r in roots):
|
if not any(abs(x - r) < 1e-4 for r in roots):
|
||||||
roots.append(x)
|
roots.append(x)
|
||||||
return sorted(roots)
|
return sorted(roots)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ── 2. Build mesh ────────────────────────────────────────────────────────────
|
||||||
# 2. Build mesh
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_mesh(grid=40):
|
def build_mesh():
|
||||||
a_vals = np.linspace(-2.5, 1.2, grid)
|
c_vals = np.linspace(*C_RANGE, GRID)
|
||||||
b_vals = np.linspace(-2.5, 2.5, grid)
|
d_vals = np.linspace(*D_RANGE, GRID)
|
||||||
|
|
||||||
# Pre-compute roots at every grid point
|
print(f' Computing roots on {GRID}×{GRID} grid…')
|
||||||
roots_grid = [[find_roots(a, b) for b in b_vals] for a in a_vals]
|
roots_grid = [[find_roots(c, d) for d in d_vals] for c in c_vals]
|
||||||
|
|
||||||
triangles = []
|
triangles = []
|
||||||
|
|
||||||
for i in range(grid - 1):
|
for i in range(GRID - 1):
|
||||||
for j in range(grid - 1):
|
for j in range(GRID - 1):
|
||||||
corners_roots = [
|
c0, c1 = c_vals[i], c_vals[i+1]
|
||||||
roots_grid[i ][j ],
|
d0, d1 = d_vals[j], d_vals[j+1]
|
||||||
roots_grid[i+1][j ],
|
|
||||||
roots_grid[i+1][j+1],
|
|
||||||
roots_grid[i ][j+1],
|
|
||||||
]
|
|
||||||
a_c = [a_vals[i], a_vals[i+1], a_vals[i+1], a_vals[i ]]
|
|
||||||
b_c = [b_vals[j], b_vals[j ], b_vals[j+1], b_vals[j+1]]
|
|
||||||
|
|
||||||
max_branch = max(len(r) for r in corners_roots)
|
r00 = roots_grid[i ][j ]
|
||||||
|
r10 = roots_grid[i+1][j ]
|
||||||
|
r11 = roots_grid[i+1][j+1]
|
||||||
|
r01 = roots_grid[i ][j+1]
|
||||||
|
|
||||||
for branch in range(max_branch):
|
# Only draw branch k when ALL four corners have it.
|
||||||
pts = []
|
# The rs[-1] fallback used previously created false triangles at
|
||||||
for k in range(4):
|
# fold edges by connecting unrelated branches — this is the main
|
||||||
rs = corners_roots[k]
|
# cause of the messy, self-intersecting geometry.
|
||||||
if rs:
|
n = min(len(r00), len(r10), len(r11), len(r01))
|
||||||
x = rs[branch] if branch < len(rs) else rs[-1]
|
|
||||||
else:
|
|
||||||
pts = None
|
|
||||||
break
|
|
||||||
pts.append((a_c[k], b_c[k], x))
|
|
||||||
|
|
||||||
if pts and len(pts) == 4:
|
for k in range(n):
|
||||||
p0, p1, p2, p3 = pts
|
p0 = (c0, d0, r00[k])
|
||||||
triangles.append((p0, p1, p2))
|
p1 = (c1, d0, r10[k])
|
||||||
triangles.append((p0, p2, p3))
|
p2 = (c1, d1, r11[k])
|
||||||
|
p3 = (c0, d1, r01[k])
|
||||||
|
|
||||||
|
# Reject quads where any edge has a large z-jump. A large
|
||||||
|
# jump indicates a branch-index mismatch near a fold line
|
||||||
|
# (sorted order is preserved within a branch but can
|
||||||
|
# "swap" across folds where adjacent branches coalesce).
|
||||||
|
if any(abs(a[2] - b[2]) > MAX_EDGE_DZ
|
||||||
|
for a, b in [(p0,p1),(p1,p2),(p2,p3),(p3,p0)]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
triangles.append((p0, p1, p2))
|
||||||
|
triangles.append((p0, p2, p3))
|
||||||
|
|
||||||
return triangles
|
return triangles
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ── 3. Flat base ─────────────────────────────────────────────────────────────
|
||||||
# 3. Add flat base
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def add_base(triangles, z_base=-2.4):
|
def add_base(triangles, z_base=-2.8):
|
||||||
a0, a1 = -2.5, 1.2
|
c0, c1 = C_RANGE
|
||||||
b0, b1 = -2.5, 2.5
|
d0, d1 = D_RANGE
|
||||||
zb, zt = z_base, z_base + 0.15
|
zb, zt = z_base, z_base + 0.15
|
||||||
|
|
||||||
triangles += [
|
triangles += [
|
||||||
((a0,b0,zt),(a1,b0,zt),(a1,b1,zt)),
|
((c0,d0,zt),(c1,d0,zt),(c1,d1,zt)),
|
||||||
((a0,b0,zt),(a1,b1,zt),(a0,b1,zt)),
|
((c0,d0,zt),(c1,d1,zt),(c0,d1,zt)),
|
||||||
((a0,b0,zb),(a1,b1,zb),(a1,b0,zb)),
|
((c0,d0,zb),(c1,d1,zb),(c1,d0,zb)),
|
||||||
((a0,b0,zb),(a0,b1,zb),(a1,b1,zb)),
|
((c0,d0,zb),(c0,d1,zb),(c1,d1,zb)),
|
||||||
]
|
]
|
||||||
walls = [
|
for (x0,y0),(x1,y1) in [
|
||||||
((a0,b0),(a1,b0)), ((a1,b0),(a1,b1)),
|
((c0,d0),(c1,d0)), ((c1,d0),(c1,d1)),
|
||||||
((a1,b1),(a0,b1)), ((a0,b1),(a0,b0)),
|
((c1,d1),(c0,d1)), ((c0,d1),(c0,d0)),
|
||||||
]
|
]:
|
||||||
for (x0,y0),(x1,y1) in walls:
|
|
||||||
triangles += [
|
triangles += [
|
||||||
((x0,y0,zb),(x1,y1,zb),(x1,y1,zt)),
|
((x0,y0,zb),(x1,y1,zb),(x1,y1,zt)),
|
||||||
((x0,y0,zb),(x1,y1,zt),(x0,y0,zt)),
|
((x0,y0,zb),(x1,y1,zt),(x0,y0,zt)),
|
||||||
]
|
]
|
||||||
return triangles
|
return triangles
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ── 4. ASCII STL output ───────────────────────────────────────────────────────
|
||||||
# 4. Write ASCII STL
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def normal(v0, v1, v2):
|
def normal(v0, v1, v2):
|
||||||
a = np.subtract(v1, v0)
|
a = np.subtract(v1, v0)
|
||||||
b = np.subtract(v2, v0)
|
b = np.subtract(v2, v0)
|
||||||
n = np.cross(a, b)
|
n = np.cross(a, b)
|
||||||
length = np.linalg.norm(n)
|
length = np.linalg.norm(n)
|
||||||
return n / length if length > 1e-14 else np.array([0, 0, 1])
|
return n / length if length > 1e-14 else np.array([0.0, 0.0, 1.0])
|
||||||
|
|
||||||
def write_ascii_stl(triangles, filename):
|
def write_ascii_stl(triangles, filename):
|
||||||
with open(filename, 'w') as f:
|
with open(filename, 'w') as f:
|
||||||
|
|
@ -134,13 +153,11 @@ def write_ascii_stl(triangles, filename):
|
||||||
print(f' Triangles : {len(triangles):,}')
|
print(f' Triangles : {len(triangles):,}')
|
||||||
print(f' File size : {size_kb:.0f} KB → {filename}')
|
print(f' File size : {size_kb:.0f} KB → {filename}')
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ── 5. Main ───────────────────────────────────────────────────────────────────
|
||||||
# 5. Main
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
print('Building butterfly catastrophe mesh (grid=40)…')
|
print(f'Building butterfly catastrophe mesh (grid={GRID})…')
|
||||||
tris = build_mesh(grid=40)
|
tris = build_mesh()
|
||||||
tris = add_base(tris)
|
tris = add_base(tris)
|
||||||
print('Writing ASCII STL…')
|
print('Writing ASCII STL…')
|
||||||
write_ascii_stl(tris, 'butterfly_catastrophe.stl')
|
write_ascii_stl(tris, 'butterfly_catastrophe.stl')
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue