2026-03-25 18:23:53 +00:00
|
|
|
|
"""
|
2026-03-25 18:28:42 +00:00
|
|
|
|
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.
|
2026-03-25 18:23:53 +00:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
2026-03-25 18:28:42 +00:00
|
|
|
|
A_FIXED = -3.0 # butterfly unfolding parameter (must be negative)
|
|
|
|
|
|
|
|
|
|
|
|
# ── Tuning ──────────────────────────────────────────────────────────────────
|
2026-03-25 19:05:53 +00:00
|
|
|
|
GRID = 80 # control-space resolution — increase to 100–120 for print
|
2026-03-25 18:28:42 +00:00
|
|
|
|
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
|
2026-03-25 19:05:53 +00:00
|
|
|
|
MAX_MATCH_DZ = 0.8 # max z-gap for inter-row branch matching
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
2026-03-25 18:28:42 +00:00
|
|
|
|
# ── 1. Root finding ──────────────────────────────────────────────────────────
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
2026-03-25 18:28:42 +00:00
|
|
|
|
def dV(x, c, d):
|
|
|
|
|
|
return 6*x**5 + 4*A_FIXED*x**3 + 2*c*x + d
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
2026-03-25 18:28:42 +00:00
|
|
|
|
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)
|
2026-03-25 18:23:53 +00:00
|
|
|
|
roots = []
|
|
|
|
|
|
for x0 in xs:
|
|
|
|
|
|
x = float(x0)
|
2026-03-25 18:28:42 +00:00
|
|
|
|
for _ in range(200):
|
|
|
|
|
|
fx = dV(x, c, d)
|
|
|
|
|
|
if abs(fx) < 1e-12:
|
|
|
|
|
|
break
|
|
|
|
|
|
dfx = d2V(x, c, d)
|
2026-03-25 18:23:53 +00:00
|
|
|
|
if abs(dfx) < 1e-14:
|
|
|
|
|
|
break
|
|
|
|
|
|
step = fx / dfx
|
|
|
|
|
|
x -= step
|
2026-03-25 18:28:42 +00:00
|
|
|
|
if abs(x) > 2.0 * X_RANGE: # diverged — abandon
|
|
|
|
|
|
break
|
2026-03-25 18:23:53 +00:00
|
|
|
|
if abs(step) < 1e-10:
|
|
|
|
|
|
break
|
2026-03-25 18:28:42 +00:00
|
|
|
|
if abs(dV(x, c, d)) < 1e-7 and abs(x) <= X_RANGE + 0.15:
|
2026-03-25 18:23:53 +00:00
|
|
|
|
if not any(abs(x - r) < 1e-4 for r in roots):
|
|
|
|
|
|
roots.append(x)
|
|
|
|
|
|
return sorted(roots)
|
|
|
|
|
|
|
2026-03-25 19:05:53 +00:00
|
|
|
|
# ── 2. Branch tracking ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def track_branches(roots_along_axis):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Track branches along one axis (fixed d, varying c) using greedy
|
|
|
|
|
|
nearest-neighbour matching. Returns a list of tracks; each track is a
|
|
|
|
|
|
list of length GRID where entry i is the root value at column i, or None
|
|
|
|
|
|
when the branch does not exist there.
|
|
|
|
|
|
|
|
|
|
|
|
Sorting-index matching (branch 0 always = branch 0) breaks at folds
|
|
|
|
|
|
because two adjacent branches coalesce, shifting every higher index by
|
|
|
|
|
|
one. Nearest-neighbour tracking follows each physical sheet through the
|
|
|
|
|
|
fold correctly: the two merging branches each get None past the fold, and
|
|
|
|
|
|
the surviving sheet keeps its track unbroken.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(roots_along_axis)
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
tracks = [[r] for r in roots_along_axis[0]]
|
|
|
|
|
|
|
|
|
|
|
|
for i in range(1, n):
|
|
|
|
|
|
curr = roots_along_axis[i]
|
|
|
|
|
|
|
|
|
|
|
|
prev_live = [(ti, t[-1]) for ti, t in enumerate(tracks)
|
|
|
|
|
|
if t[-1] is not None]
|
|
|
|
|
|
prev_matched, curr_matched = set(), set()
|
|
|
|
|
|
matches = {} # track_idx → curr_root_idx
|
|
|
|
|
|
|
|
|
|
|
|
cands = sorted(
|
|
|
|
|
|
[(abs(pv - curr[ci]), ti, ci)
|
|
|
|
|
|
for ti, pv in prev_live
|
|
|
|
|
|
for ci in range(len(curr))],
|
|
|
|
|
|
key=lambda x: x[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
for _, ti, ci in cands:
|
|
|
|
|
|
if ti not in prev_matched and ci not in curr_matched:
|
|
|
|
|
|
matches[ti] = ci
|
|
|
|
|
|
prev_matched.add(ti)
|
|
|
|
|
|
curr_matched.add(ci)
|
|
|
|
|
|
|
|
|
|
|
|
for ti, t in enumerate(tracks):
|
|
|
|
|
|
t.append(curr[matches[ti]] if ti in matches else None)
|
|
|
|
|
|
|
|
|
|
|
|
# Branches that appear for the first time at this column
|
|
|
|
|
|
for ci in range(len(curr)):
|
|
|
|
|
|
if ci not in curr_matched:
|
|
|
|
|
|
tracks.append([None] * i + [curr[ci]])
|
|
|
|
|
|
|
|
|
|
|
|
return tracks
|
|
|
|
|
|
|
|
|
|
|
|
# ── 3. Build mesh ────────────────────────────────────────────────────────────
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
2026-03-25 18:28:42 +00:00
|
|
|
|
def build_mesh():
|
|
|
|
|
|
c_vals = np.linspace(*C_RANGE, GRID)
|
|
|
|
|
|
d_vals = np.linspace(*D_RANGE, GRID)
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
2026-03-25 18:28:42 +00:00
|
|
|
|
print(f' Computing roots on {GRID}×{GRID} grid…')
|
2026-03-25 19:05:53 +00:00
|
|
|
|
# roots_grid[i][j] = sorted roots at (c_vals[i], d_vals[j])
|
2026-03-25 18:28:42 +00:00
|
|
|
|
roots_grid = [[find_roots(c, d) for d in d_vals] for c in c_vals]
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
2026-03-25 19:05:53 +00:00
|
|
|
|
# Track branches along each row (fixed d, varying c)
|
|
|
|
|
|
print(' Tracking branches…')
|
|
|
|
|
|
row_tracks = []
|
|
|
|
|
|
for j in range(GRID):
|
|
|
|
|
|
row = [roots_grid[i][j] for i in range(GRID)]
|
|
|
|
|
|
row_tracks.append(track_branches(row))
|
|
|
|
|
|
# row_tracks[j] = list of tracks; track[i] = root value at column i
|
|
|
|
|
|
|
2026-03-25 18:23:53 +00:00
|
|
|
|
triangles = []
|
|
|
|
|
|
|
2026-03-25 19:05:53 +00:00
|
|
|
|
for j in range(GRID - 1):
|
|
|
|
|
|
d0, d1 = d_vals[j], d_vals[j+1]
|
|
|
|
|
|
tracks_j = row_tracks[j]
|
|
|
|
|
|
tracks_j1 = row_tracks[j + 1]
|
|
|
|
|
|
|
|
|
|
|
|
for i in range(GRID - 1):
|
2026-03-25 18:28:42 +00:00
|
|
|
|
c0, c1 = c_vals[i], c_vals[i+1]
|
2026-03-25 19:05:53 +00:00
|
|
|
|
|
|
|
|
|
|
# Collect valid quad-edge segments for both rows at this column
|
|
|
|
|
|
# A segment is valid when both endpoints of the edge exist
|
|
|
|
|
|
segs_j = [(t[i], t[i+1], k)
|
|
|
|
|
|
for k, t in enumerate(tracks_j)
|
|
|
|
|
|
if t[i] is not None and t[i+1] is not None]
|
|
|
|
|
|
segs_j1 = [(t[i], t[i+1], k)
|
|
|
|
|
|
for k, t in enumerate(tracks_j1)
|
|
|
|
|
|
if t[i] is not None and t[i+1] is not None]
|
|
|
|
|
|
|
|
|
|
|
|
if not segs_j or not segs_j1:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Greedy nearest-neighbour matching between the two row-bands.
|
|
|
|
|
|
# Each j1-segment is claimed by at most one j-segment so we never
|
|
|
|
|
|
# emit overlapping quads.
|
|
|
|
|
|
j1_used = set()
|
|
|
|
|
|
# Sort j-segments by midpoint so iteration order is deterministic
|
|
|
|
|
|
for x00, x10, _ in sorted(segs_j, key=lambda s: (s[0] + s[1]) / 2):
|
|
|
|
|
|
best_k1 = best_x01 = best_x11 = None
|
|
|
|
|
|
best_dist = MAX_MATCH_DZ
|
|
|
|
|
|
for x01, x11, k1 in segs_j1:
|
|
|
|
|
|
if k1 in j1_used:
|
|
|
|
|
|
continue
|
|
|
|
|
|
dist = max(abs(x00 - x01), abs(x10 - x11))
|
|
|
|
|
|
if dist < best_dist:
|
|
|
|
|
|
best_dist, best_k1 = dist, k1
|
|
|
|
|
|
best_x01, best_x11 = x01, x11
|
|
|
|
|
|
|
|
|
|
|
|
if best_k1 is None:
|
2026-03-25 18:28:42 +00:00
|
|
|
|
continue
|
2026-03-25 19:05:53 +00:00
|
|
|
|
j1_used.add(best_k1)
|
2026-03-25 18:28:42 +00:00
|
|
|
|
|
2026-03-25 19:05:53 +00:00
|
|
|
|
p00 = (c0, d0, x00)
|
|
|
|
|
|
p10 = (c1, d0, x10)
|
|
|
|
|
|
p11 = (c1, d1, best_x11)
|
|
|
|
|
|
p01 = (c0, d1, best_x01)
|
|
|
|
|
|
triangles.append((p00, p10, p11))
|
|
|
|
|
|
triangles.append((p00, p11, p01))
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
|
|
|
|
|
return triangles
|
|
|
|
|
|
|
2026-03-25 19:05:53 +00:00
|
|
|
|
# ── 4. Flat base ─────────────────────────────────────────────────────────────
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
2026-03-25 18:28:42 +00:00
|
|
|
|
def add_base(triangles, z_base=-2.8):
|
|
|
|
|
|
c0, c1 = C_RANGE
|
|
|
|
|
|
d0, d1 = D_RANGE
|
2026-03-25 18:23:53 +00:00
|
|
|
|
zb, zt = z_base, z_base + 0.15
|
|
|
|
|
|
|
|
|
|
|
|
triangles += [
|
2026-03-25 18:28:42 +00:00
|
|
|
|
((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)),
|
2026-03-25 18:23:53 +00:00
|
|
|
|
]
|
2026-03-25 18:28:42 +00:00
|
|
|
|
for (x0,y0),(x1,y1) in [
|
|
|
|
|
|
((c0,d0),(c1,d0)), ((c1,d0),(c1,d1)),
|
|
|
|
|
|
((c1,d1),(c0,d1)), ((c0,d1),(c0,d0)),
|
|
|
|
|
|
]:
|
2026-03-25 18:23:53 +00:00
|
|
|
|
triangles += [
|
|
|
|
|
|
((x0,y0,zb),(x1,y1,zb),(x1,y1,zt)),
|
|
|
|
|
|
((x0,y0,zb),(x1,y1,zt),(x0,y0,zt)),
|
|
|
|
|
|
]
|
|
|
|
|
|
return triangles
|
|
|
|
|
|
|
2026-03-25 19:05:53 +00:00
|
|
|
|
# ── 5. ASCII STL output ───────────────────────────────────────────────────────
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-25 18:28:42 +00:00
|
|
|
|
return n / length if length > 1e-14 else np.array([0.0, 0.0, 1.0])
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
|
|
|
|
|
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}')
|
|
|
|
|
|
|
2026-03-25 19:05:53 +00:00
|
|
|
|
# ── 6. Main ───────────────────────────────────────────────────────────────────
|
2026-03-25 18:23:53 +00:00
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
2026-03-25 18:28:42 +00:00
|
|
|
|
print(f'Building butterfly catastrophe mesh (grid={GRID})…')
|
|
|
|
|
|
tris = build_mesh()
|
2026-03-25 18:23:53 +00:00
|
|
|
|
tris = add_base(tris)
|
|
|
|
|
|
print('Writing ASCII STL…')
|
|
|
|
|
|
write_ascii_stl(tris, 'butterfly_catastrophe.stl')
|
|
|
|
|
|
print('Done.')
|