catastrophe/butterfly_catastrophe.py

164 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Butterfly Catastrophe Surface — ASCII STL Generator
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 os
A_FIXED = -3.0 # butterfly unfolding parameter (must be negative)
# ── Tuning ──────────────────────────────────────────────────────────────────
GRID = 50 # control-space resolution — increase to 80100 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)
# ── 1. Root finding ──────────────────────────────────────────────────────────
def dV(x, c, d):
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 = []
for x0 in xs:
x = float(x0)
for _ in range(200):
fx = dV(x, c, d)
if abs(fx) < 1e-12:
break
dfx = d2V(x, c, d)
if abs(dfx) < 1e-14:
break
step = fx / dfx
x -= step
if abs(x) > 2.0 * X_RANGE: # diverged — abandon
break
if abs(step) < 1e-10:
break
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):
roots.append(x)
return sorted(roots)
# ── 2. Build mesh ────────────────────────────────────────────────────────────
def build_mesh():
c_vals = np.linspace(*C_RANGE, GRID)
d_vals = np.linspace(*D_RANGE, GRID)
print(f' Computing roots on {GRID}×{GRID} grid…')
roots_grid = [[find_roots(c, d) for d in d_vals] for c in c_vals]
triangles = []
for i in range(GRID - 1):
for j in range(GRID - 1):
c0, c1 = c_vals[i], c_vals[i+1]
d0, d1 = d_vals[j], d_vals[j+1]
r00 = roots_grid[i ][j ]
r10 = roots_grid[i+1][j ]
r11 = roots_grid[i+1][j+1]
r01 = roots_grid[i ][j+1]
# Only draw branch k when ALL four corners have it.
# The rs[-1] fallback used previously created false triangles at
# fold edges by connecting unrelated branches — this is the main
# cause of the messy, self-intersecting geometry.
n = min(len(r00), len(r10), len(r11), len(r01))
for k in range(n):
p0 = (c0, d0, r00[k])
p1 = (c1, d0, r10[k])
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
# ── 3. Flat base ─────────────────────────────────────────────────────────────
def add_base(triangles, z_base=-2.8):
c0, c1 = C_RANGE
d0, d1 = D_RANGE
zb, zt = z_base, z_base + 0.15
triangles += [
((c0,d0,zt),(c1,d0,zt),(c1,d1,zt)),
((c0,d0,zt),(c1,d1,zt),(c0,d1,zt)),
((c0,d0,zb),(c1,d1,zb),(c1,d0,zb)),
((c0,d0,zb),(c0,d1,zb),(c1,d1,zb)),
]
for (x0,y0),(x1,y1) in [
((c0,d0),(c1,d0)), ((c1,d0),(c1,d1)),
((c1,d1),(c0,d1)), ((c0,d1),(c0,d0)),
]:
triangles += [
((x0,y0,zb),(x1,y1,zb),(x1,y1,zt)),
((x0,y0,zb),(x1,y1,zt),(x0,y0,zt)),
]
return triangles
# ── 4. ASCII STL output ───────────────────────────────────────────────────────
def normal(v0, v1, v2):
a = np.subtract(v1, v0)
b = np.subtract(v2, v0)
n = np.cross(a, b)
length = np.linalg.norm(n)
return n / length if length > 1e-14 else np.array([0.0, 0.0, 1.0])
def write_ascii_stl(triangles, filename):
with open(filename, 'w') as f:
f.write('solid butterfly_catastrophe\n')
for tri in triangles:
v0, v1, v2 = [np.array(v, dtype=float) for v in tri]
nx, ny, nz = normal(v0, v1, v2)
f.write(f' facet normal {nx:.6e} {ny:.6e} {nz:.6e}\n')
f.write(' outer loop\n')
for v in (v0, v1, v2):
f.write(f' vertex {v[0]:.6e} {v[1]:.6e} {v[2]:.6e}\n')
f.write(' endloop\n')
f.write(' endfacet\n')
f.write('endsolid butterfly_catastrophe\n')
size_kb = os.path.getsize(filename) / 1024
print(f' Triangles : {len(triangles):,}')
print(f' File size : {size_kb:.0f} KB → {filename}')
# ── 5. Main ───────────────────────────────────────────────────────────────────
if __name__ == '__main__':
print(f'Building butterfly catastrophe mesh (grid={GRID})…')
tris = build_mesh()
tris = add_base(tris)
print('Writing ASCII STL…')
write_ascii_stl(tris, 'butterfly_catastrophe.stl')
print('Done.')