"""Parametric lattice fruit bowls, generated headlessly in Blender, printable without support. blender -b -P gen_bowls.py every bowl in BOWLS ONLY=solo,duo blender -b -P gen_bowls.py a subset (manifest merged, not replaced) DIMS=1 blender -b -P gen_bowls.py sizes, hole sizes, split radii - no mesh FAST=1 ... coarse grid, for pipeline checks only Design rule (the only one): a strut lying on the wall at angle beta from the meridian, on a wall sloping sigma above horizontal, is itself inclined by sin(alpha) = cos(beta) sin(sigma). Every strut must satisfy alpha >= A_MIN, so beta is budgeted per height from sigma, and the wall goes solid wherever sigma < SIG_MIN. The build measures alpha on every strut edge. The bowl is ONE open strip of quads on a surface of revolution: single : floor disc -> fillet -> wall (lattice) -> rim band -> inward curl double : bed ring -> foot bend -> outer wall (lattice) -> rim band -> rolled cap -> inner wall (lattice) down -> fillet -> floor disc Cells outside the struts are dropped, boundary vertices are walked onto the exact strut edge, then Solidify gives it thickness. One manifold shell, no booleans. """ import bpy, bmesh, numpy as np, math, os, sys, json, time HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.join(HERE, "out") FAST = os.environ.get("FAST") == "1" DIMS = os.environ.get("DIMS") == "1" A_MIN = 36.0 # deg: minimum inclination of any strut edge above horizontal SIG_MIN = 44.0 # deg: wall slope below which the wall is solid WALL = 1.6 # mm: thickness of wall, floor and struts (4 lines of 0.4) CELL = 0.8 # mm: grid cell, about half a strut SEGS = 300 if FAST else 800 STL_TRIS = 260_000 GLB_TRIS = 60_000 PLA = 1.24e-3 # g per mm3 d2r, r2d = math.radians, math.degrees # ----------------------------------------------------------------------------- profile path class Path: """A meridian, sampled every ~0.2 mm: r, z, tangent angle th (deg from +r, CCW), tag.""" def __init__(self, r, z, th): self.r, self.z, self.th, self.tag = [r], [z], [th], ["start"] @property def P(self): return self.r[-1], self.z[-1], self.th[-1] def _add(self, r, z, th, tag): self.r.append(r); self.z.append(z); self.th.append(th); self.tag.append(tag) def straight(self, L, tag, step=0.2): r, z, th = self.P; n = max(1, int(math.ceil(L / step))) for i in range(1, n + 1): t = L * i / n; self._add(r + t * math.cos(d2r(th)), z + t * math.sin(d2r(th)), th, tag) def arc(self, dth, R, tag, step=0.2): """Turn by dth degrees (CCW positive) on a circle of radius R.""" r, z, th = self.P sgn = 1 if dth > 0 else -1 cr, cz = r - sgn * R * math.sin(d2r(th)), z + sgn * R * math.cos(d2r(th)) # centre on the left (CCW) or right n = max(2, int(math.ceil(abs(d2r(dth)) * R / step))) for i in range(1, n + 1): a = th + dth * i / n self._add(cr + sgn * R * math.sin(d2r(a)), cz - sgn * R * math.cos(d2r(a)), a, tag) def ramp(self, L, th0, th1, tag, step=0.2): """Slope goes linearly from th0 to th1 over arc length L.""" r, z, _ = self.P; n = max(2, int(math.ceil(L / step))) for i in range(1, n + 1): a0, a1 = th0 + (th1 - th0) * (i - 1) / n, th0 + (th1 - th0) * i / n am = 0.5 * (a0 + a1) r += L / n * math.cos(d2r(am)); z += L / n * math.sin(d2r(am)) self._add(r, z, a1, tag) def arrays(self): r, z, th = (np.array(x, float) for x in (self.r, self.z, self.th)) s = np.concatenate([[0.0], np.cumsum(np.hypot(np.diff(r), np.diff(z)))]) return s, r, z, th, np.array(self.tag) def ramp_end_r(r, th0, th1, L): n = 400; a = th0 + (th1 - th0) * (np.arange(n) + 0.5) / n return r + np.sum(L / n * np.cos(np.radians(a))) def wall_length(r0, r_rim, th0, th1): """Arc length of a th0->th1 ramp that takes the radius from r0 to r_rim.""" L = (r_rim - r0) / (0.5 * (math.cos(d2r(th0)) + math.cos(d2r(th1)))) for _ in range(4): L *= (r_rim - r0) / (ramp_end_r(r0, th0, th1, L) - r0) return L def profile(b): """Build the meridian for a BOWLS entry. Returns (path arrays, regions). regions: {tag: (s_a, s_b, up)} for every lattice region; up = True when the path runs upward there (the inner wall of a double runs downward).""" if b["kind"] == "basket": # flat mesh floor on the bed -> small round-over -> near-vertical mesh wall. # No fillet and no inward curl: both of those pass through slopes the 30-off-vertical # rule forbids. The round-over is corner_r tall, i.e. inside the first few layers. p = Path(0.0, WALL / 2, 0.0) p.straight(b["floor_r"], "floor") if "r_rim" in b: # ramp s0 -> s1 and land exactly on r_rim p.arc(b["s0"], b.get("corner_r", 1.2), "corner") p.ramp(wall_length(p.P[0], b["r_rim"], b["s0"], b["s1"]), b["s0"], b["s1"], "wall") else: # explicit (length, end slope) segments p.arc(90.0, b.get("corner_r", 1.2), "corner") for L, th1 in b["wall"]: th0 = p.P[2] if abs(th1 - th0) < 1e-9: p.straight(L, "wall") else: p.ramp(L, th0, th1, "wall") s, r, z, th, tag = p.arrays() return (s, r, z, th, tag), {"floor": True, "wall": True} if b["kind"] == "single": p = Path(0.0, WALL / 2, 0.0) p.straight(b["floor"], "floor") p.arc(b["s0"], b["fillet"], "fillet") r0 = p.P[0] L = wall_length(r0, b["r_rim"], b["s0"], b["s1"]) p.ramp(L, b["s0"], b["s1"], "wall") p.arc(b.get("curl", 62), b.get("curl_r", 4.0), "curl") s, r, z, th, tag = p.arrays() return (s, r, z, th, tag), {"wall": True} # double gap = b["gap"] p = Path(b["foot"], 0.0, 90.0) p.straight(b.get("foot_up", 2.5), "foot") p.arc(b["s0"] - 90.0, b.get("bend_r", 5.0), "bend") # lean outward to s0 r0 = p.P[0] L = wall_length(r0, b["r_rim"], b["s0"], b["s1"]) p.ramp(L, b["s0"], b["s1"], "wall") p.arc(180.0, gap / 2, "cap") # roll over the gap s, r, z, th, tag = p.arrays() # inner wall = the outer path offset by gap along its left normal, walked backwards outer = np.isin(tag, ["start", "foot", "bend", "wall"]) ri = (r - gap * np.sin(np.radians(th)))[outer][::-1] zi = (z + gap * np.cos(np.radians(th)))[outer][::-1] thi = (th + 180.0)[outer][::-1] tgi = np.where(tag[outer][::-1] == "wall", "iwall", "ibend") sig = thi - 180.0 # slope of the inner wall, deg Rc = b["fillet"] drop = zi - Rc * (1 - np.cos(np.radians(sig))) # where a fillet of Rc would land k = int(np.argmax(drop <= WALL / 2)) # first sample (going down) that lands on the floor assert drop[k] <= WALL / 2 and tgi[k] == "ibend", "fillet must start below the inner lattice" r, z, th, tag = list(r) + list(ri[:k + 1]), list(z) + list(zi[:k + 1]), list(th) + list(thi[:k + 1]), list(tag) + list(tgi[:k + 1]) q = Path(r[-1], z[-1], th[-1]); q.r, q.z, q.th, q.tag = r, z, th, tag q.arc(-float(sig[k]), Rc, "ifillet") # turn right, down onto the floor q.z[-1] = WALL / 2 q.straight(q.P[0], "floor"); q.r[-1] = 0.0 q.th[-1] = 180.0 return q.arrays(), {"wall": True, "iwall": False} # ----------------------------------------------------------------------------- lattice field def wrap(x): return (x + np.pi) % (2 * np.pi) - np.pi M32 = np.int64(0xFFFFFFFF) def rand01(i, seed, n): """n independent uniforms in [0,1) per integer strand index -- a hash, not a generator, so strand m gets the same numbers wherever in the build it is asked about.""" out = [] x0 = i.astype(np.int64) & M32 for j in range(n): x = (x0 * np.int64(2654435761) + np.int64(seed * 2246822519 + j * 3266489917 + 1)) & M32 x = ((x ^ (x >> np.int64(15))) * np.int64(2246822519)) & M32 x = ((x ^ (x >> np.int64(13))) * np.int64(3266489917)) & M32 out.append(((x ^ (x >> np.int64(16))) & M32) / 4294967296.0) return out def vnoise(m, ncell, period, seed): """Smooth value noise in the strand index, in [-1, 1], periodic over `period` strands. Neighbouring strands see nearly the same value, so the fabric drifts in BUNDLES of about period/ncell strands instead of neighbours jumping at each other and closing a hole.""" t = m.astype(np.float64) * ncell / period i0 = np.floor(t); f = t - i0 a = i0.astype(np.int64) % ncell; b = (a + 1) % ncell h0 = rand01(a, seed, 1)[0] * 2 - 1 h1 = rand01(b, seed, 1)[0] * 2 - 1 f = f * f * (3 - 2 * f) return h0 * (1 - f) + h1 * f class Lattice: """Signed distance (positive inside a strut) of a branching diamond net on one wall region. s is the local arc length, increasing UPWARD from the bottom of the region.""" def __init__(self, s, r, sig, N, w, beta_cap, splits, phase=0.0, band_lo=2.0, band_hi=4.0, a_min=A_MIN, wave=None, stroke=None, sway=None, ogive=None, mullion=None, jitter=None): self.s, self.r, self.N, self.w = s, r, N, w self.band_lo, self.band_hi, self.smax = band_lo, band_hi, s[-1] self.ph = phase * 2 * np.pi / N # a_min may be a single angle or one per sample: the rule is allowed to be looser low # down, where the wall itself is shallow and cannot carry a steep strut anyway, and to # tighten towards the rim. Everything downstream already works per sample. a_min = np.asarray(a_min, float) * np.ones_like(s) self.a_min = a_min sig = np.radians(np.clip(sig, np.minimum(SIG_MIN, a_min), 90.0)) beta = np.minimum(d2r(beta_cap), np.arccos(np.clip(np.sin(np.radians(a_min)) / np.sin(sig), -1, 1))) assert beta.min() > d2r(3.0), \ f"a_min {a_min.max():.0f} leaves no lean on a wall at sigma {np.degrees(sig).min():.0f}: no net is possible" self.beta = beta # Both families share a twist T(s,v) = G(s) + B sin(q v + psi s), so the net stays a net: # the contours of N v -+ T are still closed curves, every strand runs band to band and # nothing acquires a free end. The wave costs beta budget on two counts - it tilts the # strands by B psi and it squeezes the hoop pitch by B q - so G' is what is left over: # |dT/ds| r / |N -+ dT/dv| <= tan beta for every v m_ = stroke or {} self.mA, self.mq, self.mpsi = float(m_.get("A", 0.0)), int(m_.get("q", 0)), float(m_.get("psi", 0.0)) assert self.mA >= 0.0 and w >= 0.8, f"strut {w:.2f} mm is under two extrusion lines" # A stroke that swells along its length tilts the strut EDGE off its centreline by # grad(half width) resolved along the strand - so the brush weight is nearly free, but # not quite, and alpha is measured on the edge. Charge that tilt to the budget. tilt = np.arctan(0.5 * w * self.mA * np.sqrt(self.mpsi ** 2 + (self.mq / r) ** 2)) beta = np.maximum(beta - tilt, 0.0) # ---- per-strand jitter. A wave that every strand shares is still a repeat: shift the # whole fabric round by one strand and it looks the same. So give each strand its OWN # curve -- its own amplitude, its own wavelength, its own phase, its own resting offset # -- drawn from a hash of the strand index, with the two families hashed separately. # Nothing about the net changes: each strand is still one continuous line from band to # band, and the drift G still carries it across every strand of the other family, so no # end comes loose. The index has to be taken MODULO the strand count, or v = 0 and # v = 2pi would hash differently and the bowl would have a seam. j_ = jitter or {} self.jA = float(j_.get("A", 0.0)) # rad of phase, peak of the slow harmonic self.jlam = float(j_.get("lam", 40.0)) # mm, its nominal wavelength self.jA2 = float(j_.get("A2", 0.0)) # a faster harmonic on top self.jlam2 = float(j_.get("lam2", 17.0)) self.jA3 = float(j_.get("A3", 0.0)) # a wave slower than the wall is tall: self.jlam3 = float(j_.get("lam3", 240.0)) # each strand simply leans its own way self.jO = float(j_.get("off", 0.0)) # rad, resting offset -> irregular pitch self.jbund = int(j_.get("bundle", 7)) # strands per offset bundle self.jspread = float(j_.get("spread", 0.45)) # strand-to-strand spread of wavelength self.jseed = int(j_.get("seed", 1)) self.jom = 2 * np.pi / self.jlam self.jom2 = 2 * np.pi / self.jlam2 self.jom3 = 2 * np.pi / self.jlam3 assert not (self.jA or self.jA2 or self.jA3 or self.jO) or not splits, \ "per-strand jitter moves the crossings, so a split could be born in mid-air" assert self.jA + self.jA2 < 0.9 * np.pi, \ "the fast harmonics alone move a strand off its own cell; lower A / A2" # The three octaves of the offset contribute 0.50/4 + 0.32/1.7 + 0.18/1 = 0.49 of the # swing per bundle, so one strand's resting place differs from the next by about # 0.99 jO / bundle. Half a pitch of that and neighbours would swap places. self.jgrad = 0.99 * self.jO / max(self.jbund, 1) assert self.jgrad < np.pi, \ "the offset changes by over half a pitch per strand: strands would swap places" # worst case |dJ/ds| over every strand -- charged to the beta budget exactly like a wave jcost = (self.jA * self.jom * (1.0 + self.jspread) + self.jA2 * self.jom2 + self.jA3 * self.jom3) # rough worst case that two neighbours close on each other; only a flag now, the real # number is measured self.jrange = 2 * (self.jA + self.jA2) + self.jgrad w_ = wave or {} B, q, psi = float(w_.get("B", 0.0)), int(w_.get("q", 0)), float(w_.get("psi", 0.0)) self.wB, self.wq, self.wpsi = B, q, psi # Sway is COMMON mode: it is subtracted from both families, so the whole fabric slides # sideways with height and every strand curves, while the crossing structure that G # sets is left intact. (Putting the wave in antiphase instead only braids each pair of # strands together and drops the hoop connection between neighbours.) # WEAVE: spend the whole budget on waviness instead of on a secular twist. The two # families wave in antiphase (T is subtracted from one and added to the other) and are # staggered half a pitch by g0, so each strand weaves PAST both neighbours and crosses # each of them twice per wavelength - four crossings per lambda, from the wave alone. # That is why this beats spending the budget on drift: the drift has to travel a whole # pitch to make one crossing, the wave makes four without going anywhere. # amplitude <= tan(beta_max) * lambda / 2pi, and B >= pi/2 or the families never meet. self.g0 = 0.0 if w_.get("mode") == "weave": lam = float(w_["lam"]); om = 2 * np.pi / lam drift = float(w_.get("drift", 0.0)) budget = float((N * np.tan(beta) / r).min()) - jcost B = float(w_.get("B", (1.0 - drift) * budget / om)) assert B * om <= (1.0 - drift) * budget + 1e-9, f"weave over budget: B*om {B * om:.4f} > {(1.0 - drift) * budget:.4f} (shorten lam or lower B)" assert B >= np.pi / 2, f"weave B {B:.2f} < pi/2: the two families would never cross" q, psi = 0, om self.wB, self.wq, self.wpsi = B, q, psi self.g0 = float(w_.get("g0", np.pi / 2)) self.drift_frac = drift y_ = sway or {} D, pp, om = float(y_.get("D", 0.0)), int(y_.get("p", 0)), float(y_.get("om", 0.0)) self.yD, self.yp, self.yom = D, pp, om squeeze = N - B * q - D * pp assert squeeze > 0, f"wave and sway reverse the hoop pitch: N {N} <= {B * q + D * pp:.1f}" # Mullions: a third family of true meridian bars. beta = 0, so alpha = sigma exactly - # the safest struts on the bowl, and they give the wall an uninterrupted vertical load path. u_ = mullion or {} self.nm = int(u_.get("n", 0)) self.mph_ = float(u_.get("phase", 0.0)) * 2 * np.pi / max(self.nm, 1) self.mw = float(u_.get("w", w)) o_ = ogive or {} if o_: # POINTED cells instead of diamonds. Two strands that meet TANGENTIALLY - both # vertical at the meeting point - form a point, which is what a lancet arch is and # what a round arch can never be under this rule. Crossings sit where G = pi m, so # modulate the lean by |sin G|: near zero at every crossing, full budget mid-cell. # eps keeps it off zero, since a true zero of dG/ds would be an equilibrium the # integration could never pass. beta depends on HEIGHT only, never on v, so the # crossings stay at fixed heights and the split birth-on-a-crossing rule still holds. eps = float(o_.get("eps", 0.12)) G = np.zeros_like(s); Gp = np.zeros_like(s) ds_ = np.diff(s); g = 0.0 for i in range(len(s)): bl = beta[i] * (eps + (1.0 - eps) * abs(math.sin(g))) Gp[i] = squeeze * math.tan(bl) / r[i] - B * abs(psi) - D * abs(om) - jcost G[i] = g if i < len(s) - 1: g += Gp[i] * ds_[i] assert Gp.min() > 0, "ogive: budget exhausted at the points; raise eps or lower the wave" self.Gp, self.G_ode = Gp, G elif w_.get("mode") == "weave": Gp = np.maximum(self.drift_frac * (N * np.tan(beta) / r) - jcost, 0.0) self.Gp, self.G_ode = Gp, None else: Gp = squeeze * np.tan(beta) / r - B * abs(psi) - D * abs(om) - jcost assert Gp.min() > 0, ("wave + sway + jitter eat the whole beta budget " f"(jitter alone is {jcost:.3f} of {(squeeze * np.tan(beta) / r).min():.3f})") self.Gp, self.G_ode = Gp, None self.G = self.g0 + (self.G_ode if self.G_ode is not None else np.concatenate([[0.0], np.cumsum(0.5 * (self.Gp[1:] + self.Gp[:-1]) * np.diff(s))])) # every doubling has to happen where the new strands are born on an existing crossing # of the other family: level L -> L+1 needs G = pi * odd / 2^(L+1) self.ssplit = [] for L, rs in enumerate(splits): if rs <= r.min() or rs >= r.max(): continue s_ideal = np.interp(rs, r, s) # r increases with s on a bowl wall q = np.interp(s_ideal, s, self.G) * 2 ** (L + 1) / np.pi q_odd = 2 * round((q - 1) / 2) + 1 self.ssplit.append(float(np.interp(q_odd * np.pi / 2 ** (L + 1), self.G, s))) def level(self, s): return sum((s >= ss).astype(int) for ss in self.ssplit) if self.ssplit else np.zeros_like(s, int) def jdraw(self, m, s, nstr, salt): """J(strand, s) and dJ/ds for strands already identified by index m. Every parameter is smooth noise ALONG the strand index, each on its own bundle length so the bundles never line up. Neighbours differ slightly, strands a bundle apart differ completely -- which is the ask, and is also the only way to keep the slot between two neighbours open: independent draws let a pair drift together and close a slot the slicer cannot fill. The resting offset is summed over three octaves, so the fabric is sparse in some sectors and crowded in others as well as jittery strand to strand, and only the finest octave -- an eighth of the swing -- moves one strand against its neighbour. """ B = self.jbund cells = lambda bund: max(int(round(nstr / max(bund, 1.0))), 2) vn = lambda bund, sd: vnoise(m, cells(bund), max(nstr, 1), self.jseed + salt + sd) o = self.jO * (0.50 * vn(4.0 * B, 91) + 0.32 * vn(1.7 * B, 101) + 0.18 * vn(1.0 * B, 113)) J, Js = o, np.zeros_like(o) if self.jA3: # slower than the wall is tall, so it reads as a per-strand lean a3 = self.jA3 * (0.5 + 0.5 * vn(2.0 * B, 127)); ph3 = np.pi * vn(1.5 * B, 139) c3 = self.jom3 * s + ph3 J = J + a3 * np.sin(c3); Js = Js + a3 * self.jom3 * np.cos(c3) a = self.jA * (0.62 + 0.38 * vn(0.8 * B, 7)) # amplitude of the slow harmonic om = self.jom * (1.0 + self.jspread * vn(1.4 * B, 23)) # its wavelength ph = np.pi * vn(1.1 * B, 37) # where in the wave it starts a2 = self.jA2 * (0.5 + 0.5 * vn(0.6 * B, 53)) ph2 = np.pi * vn(0.7 * B, 71) c1, c2 = om * s + ph, self.jom2 * s + ph2 return (J + a * np.sin(c1) + a2 * np.sin(c2), Js + a * om * np.cos(c1) + a2 * self.jom2 * np.cos(c2)) def jphase(self, p0, s, nstr, salt): """Which strand a point belongs to, then that strand's curve. round(p0 / 2pi) is only the right index while |J| stays under half a pitch, and the offset is deliberately bigger than that -- a bundle slides a pitch or more sideways, which is most of what makes the fabric look hand-drawn. Past that the naive index picks the NEIGHBOURING cell and the field cuts the strut in half lengthwise, which shows up as 28 deg struts in the audit. So solve m = round((p0 - J(m)) / 2pi). J changes by only a fraction of a pitch from one strand to the next, so one correction is enough, and the index is then constant over the whole cell around its strand and jumps only at the far edge of the void, where F is far too negative to reach any vertex. """ if not (self.jA or self.jA2 or self.jA3 or self.jO): return 0.0, 0.0 n = max(nstr, 1) m = np.mod(np.round(p0 / (2 * np.pi)).astype(np.int64), n) J, _ = self.jdraw(m, s, nstr, salt) m = np.mod(np.round((p0 - J) / (2 * np.pi)).astype(np.int64), n) return self.jdraw(m, s, nstr, salt) def fields(self, s, v): """(lattice field, band field) at local (s, v).""" r = np.interp(s, self.s, self.r); G = np.interp(s, self.s, self.G); Gp = np.interp(s, self.s, self.Gp) Nv = self.N * (v + self.ph) # brush weight swells from w up to w(1 + 2A): w itself is the thinnest printed width hw = 0.5 * self.w * (1.0 + self.mA * (1.0 + np.sin(self.mq * v + self.mpsi * s))) wp = self.wq * v + self.wpsi * s T = G + self.wB * np.sin(wp) # the twist, waved Ts = Gp + self.wB * self.wpsi * np.cos(wp) # dT/ds Tv = self.wB * self.wq * np.cos(wp) # dT/dv yp = self.yp * v + self.yom * s D = self.yD * np.sin(yp) # common-mode sway Ds = self.yD * self.yom * np.cos(yp) Dv = self.yD * self.yp * np.cos(yp) FA = np.full_like(s, -1e9); FB = np.full_like(s, -1e9); fa_prev = fb_prev = None for L in range(len(self.ssplit) + 1): k = 2 ** L pa = k * (Nv - T - D); pb = k * (Nv + T - D) Ja, Jas = self.jphase(pa, s, self.N * k, 4 * L + 1) Jb, Jbs = self.jphase(pb, s, self.N * k, 4 * L + 2) gma = np.hypot(k * (Ts + Ds) + Jas, k * (self.N - Tv - Dv) / r) # |grad phase| gmb = np.hypot(k * (-Ts + Ds) - Jbs, k * (self.N + Tv - Dv) / r) fa = hw - np.abs(wrap(pa - Ja)) / gma fb = hw - np.abs(wrap(pb - Jb)) / gmb if L: # below the split a new strand is confined to the old crossing family: born on it, not in mid-air below = s < self.ssplit[L - 1] fa, fb = np.where(below, np.minimum(fa, fb_prev), fa), np.where(below, np.minimum(fb, fa_prev), fb) FA = np.maximum(FA, fa); FB = np.maximum(FB, fb); fa_prev, fb_prev = fa, fb F = np.maximum(FA, FB) if self.nm: F = np.maximum(F, 0.5 * self.mw - r * np.abs(wrap(self.nm * (v + self.mph_))) / self.nm) band = np.maximum(self.band_lo - s, s - (self.smax - self.band_hi)) return F, band def F(self, s, v): a, b = self.fields(s, v); return np.maximum(a, b) def pitch_report(self): L = self.level(self.s); n = self.N * 2 ** L hoop = 2 * np.pi * self.r / n beff = np.arctan(self.Gp * self.r / self.N) # what the wave actually leaves; == beta when B = 0 gap = hoop * np.cos(beff) m = (self.s > self.band_lo) & (self.s < self.smax - self.band_hi) lo, hi = (gap - self.w * (1 + 2 * self.mA))[m].min(), (gap - self.w)[m].max() if getattr(self, "jrange", 0.0): lo, hi = self.measured_gaps() # jitter makes every cell a different size: measure return lo, hi, np.degrees(beff[m]).min(), np.degrees(beff[m]).max() def measured_gaps(self): """(narrowest slot, widest hole) read off a jittered wall. The *narrowest* number can not be the smallest void chord: a diamond cell tapers to a point at each corner, so that is zero by construction on every bowl here, jitter or not. What jitter can genuinely spoil is two NEIGHBOURS of the same family drifting together over a long run -- a slot too thin for the slicer to fill. Strand m sits where the phase equals 2 pi m + J(m), so with the phase linear in v the spacing of neighbours is exactly r (2 pi + J(m+1) - J(m)) / N; no root finding, and none of the false crossings that reading wrap(phase - J) off a grid would give at the cell boundary, where J steps. The *widest* number is the true void chord, which is what the eye reads as the hole. One level only: jitter and splits are mutually exclusive, asserted in __init__. """ assert self.wq == 0 and self.yp == 0, "measured_gaps assumes the phase is linear in v" m = (self.s > self.band_lo) & (self.s < self.smax - self.band_hi) ss = self.s[m][:: max(len(self.s[m]) // 60, 1)] v = np.linspace(0, 2 * np.pi, 4000, endpoint=False) dv = 2 * np.pi / len(v) wmax = self.w * (1 + 2 * self.mA) slot, hole = 1e9, 0.0 for si in ss: sv = np.full_like(v, si) r = float(np.interp(si, self.s, self.r)) Gp = float(np.interp(si, self.s, self.Gp)) lean = math.cos(math.atan(abs(Gp) * r / self.N)) for salt in (1, 2): J, _ = self.jdraw(np.arange(self.N), np.full(self.N, si), self.N, salt) d = np.diff(np.concatenate([J, [J[0]]])) # sums to zero round the bowl slot = min(slot, ((2 * np.pi + d) * r / self.N * lean).min() - wmax) F = self.F(sv, v) > 0 if F.all() or not F.any(): continue k = np.flatnonzero(F[1:] != F[:-1]) run = np.diff(np.concatenate([k, [k[0] + len(v)]])) * dv * r void = run[0::2] if not F[(k[0] + 1) % len(v)] else run[1::2] if len(void): hole = max(hole, void.max()) return slot, hole # ----------------------------------------------------------------- free strands # The seven equations scrawl / thicket / weft were drawn from. Frozen: thicket is on the # plate, and rng.choice over a longer tuple would draw a different bowl under the same seed. SHAPES = ("line", "sine", "zigzag", "saw", "chirp", "walk", "beat") # Three more, opted into by name. kink is the one that changes the character most: long # straight runs with a couple of hard elbows, so a strand reads as a decision rather than # a wave. damp and gust put the disorder somewhere in particular instead of everywhere. SHAPES2 = SHAPES + ("kink", "damp", "gust") def _sq(x, k=3.0): """A square wave with rounded corners. A true corner is printable -- both sides of it obey the slope rule -- but it makes the distance field non-smooth exactly where the audit wants |grad F| = 1, so the apex gets rounded over a few tenths of a millimetre instead.""" return np.tanh(k * np.sin(x)) / math.tanh(k) def _zero(g, s): """Mean zero -- the strand's overall lean is solved for separately, so a shape that carried its own mean would eat the budget twice -- and never outside [-1, 1], which is the cap.""" g = g - np.sum(0.5 * (g[1:] + g[:-1]) * np.diff(s)) / (s[-1] - s[0]) return g / max(float(np.abs(g).max()), 1.0) def _shape(kind, s, rng): """g(s) in [-1, 1]: this strand's own equation, given as a slope profile. Integrating it gives the path, so a square wave here draws a triangle on the wall and an asymmetric square draws a sawtooth. Mean zero, so the strand's overall lean is set separately.""" S = s[-1] ph = rng.uniform(0, 2 * np.pi) if kind == "line": return np.zeros_like(s) if kind == "sine": return np.sin(2 * np.pi * s / rng.uniform(22.0, 75.0) + ph) if kind == "zigzag": # square slope -> triangle path return _sq(2 * np.pi * s / rng.uniform(16.0, 55.0) + ph) if kind == "saw": # asymmetric square -> sawtooth path lam, d, e = rng.uniform(20.0, 60.0), rng.uniform(0.28, 0.46), 0.05 u = (s / lam + rng.random()) % 1.0 up = 0.5 * (np.tanh((u - 0.5 * e) / e) - np.tanh((u - d) / e)) back = d / (1.0 - d) # so the mean stays zero return -back + (1.0 + back) * up if kind == "chirp": # wavelength sweeps up the wall lo, hi = rng.uniform(14.0, 26.0), rng.uniform(45.0, 95.0) lam = lo + (hi - lo) * s / S return np.sin(2 * np.pi * S / (hi - lo) * np.log(lam / lo) + ph) if kind == "walk": # smooth random steps: no equation n = int(rng.integers(3, 9)) h = rng.uniform(-1, 1, n + 1) t = s / S * n; i0 = np.minimum(t.astype(int), n - 1); f = t - i0 f = f * f * (3 - 2 * f) return h[i0] * (1 - f) + h[i0 + 1] * f if kind == "beat": # two wavelengths, never in step a = np.sin(2 * np.pi * s / rng.uniform(18.0, 34.0) + ph) b = np.sin(2 * np.pi * s / rng.uniform(48.0, 110.0) + rng.uniform(0, 2 * np.pi)) return 0.62 * a + 0.38 * b if kind == "kink": # long straight runs, sudden elbows n = int(rng.integers(2, 5)) cut = np.sort(rng.uniform(0.12, 0.88, n)) * S h = rng.uniform(0.55, 1.0, n + 1) * (-1.0) ** np.arange(n + 1) * rng.choice([-1.0, 1.0]) g = np.full_like(s, h[0]) for k in range(n): # 0.9 mm elbows: sharp, still smooth g = g + 0.5 * (h[k + 1] - h[k]) * (1 + np.tanh((s - cut[k]) / 0.9)) return _zero(g, s) if kind == "damp": # busy at one end, calm at the other env = np.exp(-s / rng.uniform(20.0, 45.0)) if rng.random() < 0.5: env = env[::-1] return _zero(env * np.sin(2 * np.pi * s / rng.uniform(15.0, 34.0) + ph), s) if kind == "gust": # quiet, one burst of turbulence, quiet c, wd = rng.uniform(0.2, 0.8) * S, rng.uniform(0.08, 0.20) * S return _zero(np.exp(-0.5 * ((s - c) / wd) ** 2) * np.sin(2 * np.pi * s / rng.uniform(7.0, 15.0) + ph), s) raise ValueError(kind) class StrandField: """Everything a wall of strands shares, once you stop assuming how the strands were made. A strand here is a curve v_i(s) with a span [SA_i, SB_i] and a half width hw_i, and that is the whole contract. The distance field, the four ways a fabric fails (thin slot, fused run, unsupported run, clumping) and the hole report are all written against it and against nothing else. What differs between generators is only how the curves are DRAWN -- * FreeLattice picks each strand's own equation from a library, * FlowLattice makes every strand an integral curve of one field shared by the whole wall, * VeinLattice grows them, forking and dying to hold a pitch -- so a generator supplies V, Vd, hw, SA, SB and inherits the rest. """ def _install(self, s, r, V, Vd, hw, SA, SB, band_lo, band_hi, beta, a_min, danger=0.55): self.s, self.r, self.N = s, r, len(V) self.V, self.Vd, self.hw, self.SA, self.SB = V, Vd, hw, SA, SB self.band_lo, self.band_hi, self.smax = band_lo, band_hi, s[-1] self.beta, self.a_min, self.ssplit = beta, a_min, [] assert (np.abs(Vd) * r <= np.tan(beta) + 1e-9).all(), "a strand broke the slope cap" self.worst_run, self.merged, self.free_run = self.scan(danger) def scan(self, danger=0.55): """worst thin slot, worst fused run, worst unsupported run -- over the whole region. A strand is only judged where it exists; a stretch it never reaches is not a free run.""" m = (self.s > self.band_lo) & (self.s < self.smax - self.band_hi) sb, rb = self.s[m], self.r[m] V, Vd, hw = self.V[:, m], self.Vd[:, m], self.hw AL = (sb[None, :] >= self.SA[:, None]) & (sb[None, :] <= self.SB[:, None]) def longest(row): if not row.any(): return 0.0 e = np.flatnonzero(np.diff(np.concatenate([[0], row.view(np.int8), [0]]))) return float((sb[np.minimum(e[1::2], len(sb) - 1)] - sb[e[0::2]]).max()) slot = merge = free = 0.0 for i in range(self.N): gap = np.abs(rb * wrap(V - V[i])) / np.hypot(1.0, rb * 0.5 * (Vd + Vd[i])) - (hw + hw[i])[:, None] gap = np.where(AL & AL[i], gap, 1e9) gap[i] = 1e9 slot = max(slot, max((longest(row & AL[i]) for row in (gap > 0) & (gap < danger)), default=0.0)) merge = max(merge, max((longest(row & AL[i]) for row in gap < -0.05), default=0.0)) free = max(free, longest(~(gap <= 0).any(0) & AL[i])) return slot, merge, free def cap_mask(self, s, v): """True where the nearest strand is a rounded END rather than its shaft. This is where alpha stops being the right question. alpha measures the inclination of the boundary and stands in for how far the solid steps sideways per layer -- but on a capsule's rounded end the boundary sweeps through every inclination there is, including dead horizontal, while the solid's sideways advance never once exceeds the shaft's own, because the cap is what the shaft shrank into. A tip therefore reads as a 0 deg overhang and is not one; that was 1787 false vertices in the first coral. Only ENDS are exempted, and the exemption is safe in the direction that matters: a cap at the BOTTOM of a strand, which really is a hemisphere resting on air, is caught by the Fs > 0 test instead, and a strand may only start on the bed or on a parent anyway. """ r = np.interp(s, self.s, self.r) best = np.full(np.shape(s), -1e9, float); cap = np.zeros(np.shape(s), bool) for i in range(self.N): p = np.interp(s, self.s, self.V[i]); q = np.interp(s, self.s, self.Vd[i]) d = np.abs(r * wrap(v - p)) / np.hypot(1.0, r * q) ax = np.maximum(np.maximum(self.SA[i] - s, s - self.SB[i]), 0.0) f = self.hw[i] - np.hypot(d, ax) m = f > best best = np.where(m, f, best); cap = np.where(m, ax > 0.0, cap) return cap def level(self, s): return np.zeros_like(s, int) def fields(self, s, v): r = np.interp(s, self.s, self.r) F = np.full(np.shape(s), -1e9, float) for i in range(self.N): p = np.interp(s, self.s, self.V[i]) q = np.interp(s, self.s, self.Vd[i]) d = np.abs(r * wrap(v - p)) / np.hypot(1.0, r * q) # A strand that starts partway along ends in a ROUND cap, not a cut: the distance to # the segment rather than to the infinite line keeps |grad F| = 1 through the tip, # which is what the boundary walk and the angle audit both stand on. ax = np.maximum(np.maximum(self.SA[i] - s, s - self.SB[i]), 0.0) F = np.maximum(F, self.hw[i] - np.hypot(d, ax)) band = np.maximum(self.band_lo - s, s - (self.smax - self.band_hi)) return F, band def F(self, s, v): a, b = self.fields(s, v); return np.maximum(a, b) def pitch_report(self): """Hole diameters, honestly: -F inside a void IS the distance to the nearest strand, so the inradius of each hole is a local maximum of it and the hole is twice that.""" m = (self.s > self.band_lo) & (self.s < self.smax - self.band_hi) ss = self.s[m][:: max(len(self.s[m]) // 90, 1)] v = np.linspace(0, 2 * np.pi, 2400, endpoint=False) SS, VV = np.meshgrid(ss, v, indexing="ij") D = -self.F(SS.ravel(), VV.ravel()).reshape(SS.shape) # +ve inside a hole c = D[1:-1, :] pk = c > 0 for di in (-1, 0, 1): for dj in (-1, 0, 1): if di or dj: pk &= c >= np.roll(np.roll(D, -di, 0), -dj, 1)[1:-1, :] d = 2 * c[pk] b = np.degrees(np.arctan(np.abs(self.Vd) * self.r)) if not len(d): return 0.0, 0.0, b.min(), b.max() return float(np.percentile(d, 1)), float(d.max()), b.min(), b.max() class FreeLattice(StrandField): """A wall net whose strands are INDIVIDUAL curves, each with its own equation. Every other lattice here is built from one global phase, N v -+ G(s). That is what makes a bowl exactly N-fold symmetric about its axis and every strand a copy of its neighbour, and no amount of jitter on the parameters of that phase changes either fact -- the symmetry is in the form, not in the numbers. So this class has no phase and no N. Strand i is a curve v_i(s) integrated from its own slope profile: a straight lean, a sine, a square wave (a triangle on the wall), an asymmetric square (a sawtooth), a chirp whose wavelength sweeps, a smooth random walk, or two wavelengths beating. Each also draws its own share of the budget to lean with, its own direction round the bowl, its own starting place and its own wire width. The field is just the union of the distance to each strand. The only thing every strand obeys is the one rule. A strand leaning beta off the meridian on a wall of slope sigma is inclined sin alpha = cos beta sin sigma, and beta = atan(r dv/ds), so the rule is exactly a cap on |dv/ds| -- and because each strand keeps a constant width, its edges run parallel to its centreline and the same cap holds for the edges the audit actually measures. Anything at all is allowed underneath that cap. Nothing else is imposed: no strand count, no pitch, no symmetry, no shared shape. """ def __init__(self, s, r, sig, K, w, a_min, band_lo, band_hi, spec, on_bed=False): self.s, self.r, self.w, self.N = s, r, w, K self.band_lo, self.band_hi, self.smax = band_lo, band_hi, s[-1] self.on_bed, self.ssplit = on_bed, [] a_min = np.asarray(a_min, float) * np.ones_like(s) self.a_min = a_min sg = np.radians(np.clip(sig, np.minimum(SIG_MIN, a_min), 90.0)) beta = np.arccos(np.clip(np.sin(np.radians(a_min)) / np.sin(sg), -1, 1)) self.beta = np.minimum(beta, d2r(float(spec.get("beta", 90.0)))) # the whole printability rule, in one line: how fast a strand may turn round the bowl # 0.97, and that 3% is doing real work. A strand's EDGE is only parallel to its # centreline where r and the slope are locally constant; where it turns hard the edge # bends a little more than the middle does, and it is the edge the audit measures. At # thicket's foot the 3% is worth 0.4 deg of headroom, which vein's harder-leaning # strands ate straight through -- hence cap_margin, lower where the curves are sharper. cap = float(spec.get("cap_margin", 0.97)) * np.tan(self.beta) / r rng = np.random.default_rng(int(spec.get("seed", 1))) kinds = tuple(spec.get("shapes", SHAPES)) swing = tuple(spec.get("swing", (0.45, 1.0))) flip, wvar = float(spec.get("flip", 0.06)), float(spec.get("wvar", 1.4)) jit = float(spec.get("start_jitter", 0.85)) drift = float(spec.get("drift", 0.85)) # share of budget spent going somewhere dvar = float(spec.get("drift_var", 0.06)) # how much that varies strand to strand danger = float(spec.get("danger", 0.55)) SLOT = float(spec.get("max_slot_run", 6.0)) # a thin slot pinches into a bow-tie MERGE = float(spec.get("max_merge_run", 16.0)) # two strands fused too long = one blob FREE = float(spec.get("max_free_run", 30.0)) # unsupported wire long enough to buckle HOLE = float(spec.get("max_hole", 16.0)) # or the fabric clumps and leaves voids tries = int(spec.get("tries", 40)) ds = np.diff(s) band = (s > band_lo) & (s < self.smax - band_hi) step = max(int(band.sum()) // 130, 1) # the solver works on a coarse height set sub = np.flatnonzero(band)[::step] sb, rb = s[sub], r[sub] Ic = float(np.sum(0.5 * (cap[1:] + cap[:-1]) * ds)) # total turn available, rad # ---- Spans. A strand does not have to run the whole region. `born` of the K strands # start partway along instead of at the inner band, and that is the ONLY way to keep the # angular pitch sane on a floor: a disc's circumference grows eightfold from hub to rim, # so a fixed strand count is either fused at the hub or 40 mm apart at the edge -- which # is exactly what the first free floor did. Birth radii spread over the band give a # count that grows with r, i.e. roughly constant pitch. # Births are allowed ON THE BED ONLY, and that restriction is the whole safety # argument. A strand starting in mid-air on a wall ends in a hemispherical cap whose # underside is a 100% overhang, and the alpha audit would never see it: the audit # measures edge inclination and the cap's edges are fine. On the plate there is no # underside at all, so a birth there costs nothing. SA = np.full(K, s[0]); SB = np.full(K, s[-1]) born = int(spec.get("born", 0)) assert born == 0 or on_bed, "strands may only be born mid-region on a mesh floor" if born: lo, hi = spec.get("born_span", (0.12, 0.92)) q = (np.arange(born) + 0.5) / born # spread, then jittered u = np.clip(lo + (hi - lo) * (q + (rng.random(born) - 0.5) / born), 0.0, 1.0) SA[K - born:] = band_lo + u * (self.smax - band_hi - band_lo) alive = (s[None, :] >= SA[:, None]) & (s[None, :] <= SB[:, None]) AL = alive[:, sub] # ---- Free strands need a SOLVER, not a formula. Nothing in the equations keeps two of # them apart or brings them together, and four separate things go wrong on their own: # * a pair running a long way a HAIR apart leaves a slot too thin for the slicer to # fill -- that is what snaps into a bow-tie. Touching is fine, it is a crossing; # the near miss is what kills. # * a pair fused for a long way is one fat blob instead of two lines. # * a strand that meets nobody is a long unsupported wire, and it will buckle. # * left to themselves the strands clump and the fabric opens 40 mm voids. # The last two are the ones that are not obvious. A rule that only punishes near misses # is happiest when every strand is alone, so it spreads them into a few enormous holes; # add a coverage cap and it wants to clump instead. # Blind search will not satisfy all four, and the reason is worth writing down: if # each strand picks its own mean lean, two strands of the same family slide FOUR pitches # past each other over the height of the wall, and no amount of redrawing fixes a # fabric that is shearing itself apart. So the mean is not free. Each strand is made # to arrive at the same total turn as the rest of its family (give or take drift_var), # by solving for the lean that its own shape leaves room for: # integral( cap (m + a g) ds ) = D -> m = (D - a * integral(cap g)) / integral(cap) # Same start, same finish, completely free in between. That is what keeps it a fabric # while leaving every line its own equation. def longest(row): if not row.any(): return 0.0 e = np.flatnonzero(np.diff(np.concatenate([[0], row.view(np.int8), [0]]))) return float((sb[np.minimum(e[1::2], len(sb) - 1)] - sb[e[0::2]]).max()) def pair_gap(v, vd, hw, al, idx): """centre-to-centre distance less the two half widths, for every placed strand at every sampled height -- and +inf wherever either of the pair is not there.""" if not len(idx): return np.zeros((0, len(sub))), al g = (np.abs(rb * wrap(V[idx][:, sub] - v[sub])) / np.hypot(1.0, rb * 0.5 * (Vd[idx][:, sub] + vd[sub])) - (HW[idx][:, None] + hw)) return np.where(AL[idx] & al, g, 1e9), al def widest_void(V, HW, AL): """largest hoop gap between angularly adjacent LIVE strands, at any height""" P = np.mod(V[:, sub], 2 * np.pi) worst = 0.0 for j in range(len(sub)): q = np.sort(P[AL[:, j], j]) if len(q) < 2: return 1e9 d = (np.append(q[1:], q[0] + 2 * np.pi) - q) * rb[j] worst = max(worst, float(d.max() - 2 * HW.mean())) return worst def penalty(gap_al, use_all): gap, al = gap_al slot = max((longest(row & al) for row in (gap > 0) & (gap < danger)), default=0.0) pen = max(0.0, slot - SLOT) if not use_all: return pen return pen + max(0.0, max((longest(row & al) for row in gap < -0.05), default=0.0) - MERGE) def widest_gap_at(i, placed): """midpoint of the widest gap at strand i's birth radius -- where a new strand does the most good. This is the structural half of the void fix: the solver's objective is local and symmetric on purpose, so it never sees a void at all.""" j = int(np.argmin(np.abs(sb - SA[i]))) k = [q for q in placed if AL[q, j]] if len(k) < 2: return 2 * np.pi * rng.random() q = np.sort(np.mod(V[k, sub[j]], 2 * np.pi)) d = np.append(q[1:], q[0] + 2 * np.pi) - q g = int(np.argmax(d)) return q[g] + 0.5 * d[g] def draw(i, base): """base = None means the old behaviour: a start angle on the ring, jittered. The jitter draw happens either way so that pinning a seed pins the same bowl whether or not the region has births -- thicket is on the plate and must stay reproducible.""" kind = str(rng.choice(kinds)) g = _shape(kind, s, rng) aa = float(rng.uniform(*swing)) Ig = float(np.sum(0.5 * (cap[1:] * g[1:] + cap[:-1] * g[:-1]) * ds)) D = drift * Ic * (1.0 + dvar * (2 * rng.random() - 1)) for _ in range(8): # shrink the swing until the lean fits m = (D - aa * Ig) / Ic if 0.0 <= m and m + aa <= 1.0: break aa *= 0.75 m = min(max(m, 0.0), 1.0 - aa) sgn = (-1.0) ** i * (-1.0 if rng.random() < flip else 1.0) vd = sgn * cap * (m + aa * g) u = rng.random() # over the ROOT count, not K: the strands that start at the inner band have to ring # the whole way round on their own, and dividing by K would crowd them all into the # first K_root/K of the circle (with born = 0 this is the old expression exactly). v0 = base if base is not None else 2 * np.pi * (i + jit * (u - 0.5)) / max(K - born, 1) v = np.concatenate([[0.0], np.cumsum(0.5 * (vd[1:] + vd[:-1]) * ds)]) return kind, vd, v - np.interp(SA[i], s, v) + v0, 0.5 * w * float(rng.uniform(1.0, wvar)) V = np.zeros((K, len(s))); Vd = np.zeros_like(V); HW = np.zeros(K); kind_of = [""] * K for i in range(K): # first pass: lay them down v0 = widest_gap_at(i, range(i)) if i >= K - born else None best = None for _ in range(tries): kind, vd, v, hw = draw(i, v0) pen = penalty(pair_gap(v, vd, hw, AL[i], np.arange(i)), False) if best is None or pen < best[0]: best = (pen, kind, vd, v, hw) if pen <= 0: break _, kind_of[i], Vd[i], V[i], HW[i] = best for _ in range(int(spec.get("sweeps", 6))): # then repair, strand by strand worst = 0.0 for i in range(K): o = np.flatnonzero(np.arange(K) != i) def pen_of(v, vd, hw): return penalty(pair_gap(v, vd, hw, AL[i], o), True) keep = (pen_of(V[i], Vd[i], HW[i]), kind_of[i], Vd[i], V[i], HW[i]) if keep[0] > 0: v0 = float(np.interp(SA[i], s, V[i])) if i >= K - born else None for _ in range(tries): kind, vd, v, hw = draw(i, v0) pen = pen_of(v, vd, hw) if pen < keep[0]: keep = (pen, kind, vd, v, hw) if pen <= 0: break _, kind_of[i], Vd[i], V[i], HW[i] = keep worst = max(worst, keep[0]) if worst <= 0: break self.V, self.Vd, self.hw, self.kind = V, Vd, HW, kind_of self.SA, self.SB, self.born = SA, SB, born assert (np.abs(self.Vd) * r <= np.tan(self.beta) + 1e-12).all(), "a strand broke the slope cap" self.worst_run, self.merged, self.free_run = self.scan(danger) self.void = widest_void(V, HW, AL) assert (self.worst_run <= SLOT and self.merged <= MERGE and self.free_run <= FREE and self.void <= HOLE), ( f"placement did not settle: slot {self.worst_run:.1f}/{SLOT:.0f}, " f"merge {self.merged:.1f}/{MERGE:.0f}, free {self.free_run:.1f}/{FREE:.0f}, " f"void {self.void:.1f}/{HOLE:.0f} mm") # ------------------------------------------------------------------- a field, not a strand def _steer(spec, rng, s, r): """theta(s, v) in [-1, 1]: a steering field belonging to the WALL, not to any strand. This is a change of ownership, and it is the whole idea. In FreeLattice the shape is a property of the strand -- strand 12 is a sawtooth wherever it happens to be -- so the pattern has no places in it, only K unrelated squiggles, and it therefore looks the same everywhere however wild each squiggle is. Here the shape is a property of the POSITION: every strand crossing the same patch of wall is bent the same way by it. Strands bundle, braid, part around something and close up again, and the bowl gets structure at a scale bigger than one strand -- a calm side, a turbulent one, an eddy -- which the old model could not represent at all, at any parameter setting. Two ingredients, and the second is the one that matters. Harmonics sin(m v + w s + p) with several coprime m give the large slow swirl and no rotational symmetry (a single m would give exactly m-fold, which is the trap the phase lattice fell into). Obstacles are the rest: a derivative-of-Gaussian at (s_j, v_j) steers strands AROUND a point. A sum of harmonics can never do that -- it is periodic by construction, so it always ends up reading as wallpaper -- and a rock in the stream is exactly what wallpaper is not. """ nh = int(spec.get("modes", 6)) m = rng.integers(1, int(spec.get("mmax", 6)) + 1, nh).astype(float) lam = rng.uniform(*spec.get("vlam", (24.0, 110.0)), nh) sgn = rng.choice([-1.0, 1.0], nh) ph = rng.uniform(0, 2 * np.pi, nh) a = rng.uniform(0.35, 1.0, nh); a /= np.sqrt(np.sum(a ** 2)) nr = int(spec.get("rocks", 5)) rs = rng.uniform(0.12, 0.88, nr) * s[-1] # where each rock sits rv = rng.uniform(0, 2 * np.pi, nr) ra = rng.uniform(*spec.get("rock_h", (9.0, 26.0)), nr) # its reach up the wall, mm rb = rng.uniform(*spec.get("rock_w", (8.0, 24.0)), nr) # and round it, mm rA = rng.uniform(0.8, 2.0, nr) * rng.choice([-1.0, 1.0], nr) rr = np.interp(rs, s, r) gain = float(spec.get("gain", 1.6)) def theta(sq, vq): t = np.zeros(np.shape(vq), float) for k in range(nh): t += a[k] * np.sin(m[k] * vq + sgn[k] * 2 * np.pi * sq / lam[k] + ph[k]) for j in range(nr): u = rr[j] * wrap(vq - rv[j]) / rb[j] t += rA[j] * np.exp(-0.5 * ((sq - rs[j]) / ra[j]) ** 2) * u * np.exp(-0.5 * u * u) return np.tanh(gain * t) return theta class FlowLattice(StrandField): """Every strand is an integral curve of one vector field shared by the whole wall. dv/ds = cap(s) * clip( +-mu_i + lam_i * theta(s, v), -1, +1 ) and that clip IS the printability rule, applied at every step to every strand, so nothing downstream can violate it however violent the field gets. Two families, +mu and -mu, feeling the same field; the net is where they cross. What the solver is, and is not. There is no search over shapes here, because a strand has no shape of its own to search -- the field decides. Instead: * two strands of the SAME family can never cross, because they solve the same ODE and solutions are unique. That kills, for free, the failure that took the longest to find in FreeLattice: same-family strands shearing four pitches past each other. It also means the fabric cannot tangle, only crowd. * so the only free variables left are the K start angles, and the solver is a RELAXATION on them: integrate, measure the tightest gap each adjacent pair reaches anywhere up the wall, then stretch the roomy start spacings and squeeze the tight ones, and integrate again. Twenty passes, no penalty function, no redrawing. Crowding is what the flow does on purpose -- that is the braid -- so the relaxation is not trying to make the spacing even. It is trying to make every pair's WORST moment equally bad, which is a different thing and is what leaves the bunching visible. """ def __init__(self, s, r, sig, K, w, a_min, band_lo, band_hi, spec): a_min = np.asarray(a_min, float) * np.ones_like(s) sg = np.radians(np.clip(sig, np.minimum(SIG_MIN, a_min), 90.0)) beta = np.minimum(np.arccos(np.clip(np.sin(np.radians(a_min)) / np.sin(sg), -1, 1)), d2r(float(spec.get("beta", 90.0)))) cap = float(spec.get("cap_margin", 0.97)) * np.tan(beta) / r rng = np.random.default_rng(int(spec.get("seed", 1))) theta = _steer(spec, rng, s, r) mu = float(spec.get("lean", 0.50)) lam = float(spec.get("steer", 0.50)) mvar = float(spec.get("lean_var", 0.10)) wvar = float(spec.get("wvar", 1.4)) SLOT = float(spec.get("max_slot_run", 6.0)) MERGE = float(spec.get("max_merge_run", 16.0)) FREE = float(spec.get("max_free_run", 30.0)) HOLE = float(spec.get("max_hole", 16.0)) danger = float(spec.get("danger", 0.55)) fam = np.where(np.arange(K) % 2 == 0, 1.0, -1.0) # a little per-strand lean so two same-family strands do not share an attractor and # collapse onto one line; without it a strongly convergent field fuses whole bundles mu_i = mu * (1.0 + mvar * (2 * rng.random(K) - 1)) lam_i = lam * (1.0 + 0.15 * (2 * rng.random(K) - 1)) hw = 0.5 * w * rng.uniform(1.0, wvar, K) ds = np.diff(s) def integrate(v0): V = np.empty((K, len(s))); v = v0.copy() V[:, 0] = v for j in range(len(s) - 1): h = ds[j] c = min(cap[j], cap[j + 1]) # the step obeys the tighter of the two ends, f = c * np.clip(fam * mu_i + lam_i * theta(s[j], v), -1.0, 1.0) # so the exact vm = v + 0.5 * h * f # difference f = c * np.clip(fam * mu_i + lam_i * theta(s[j] + 0.5 * h, vm), -1.0, 1.0) v = v + h * f # quotient below is inside the cap at both V[:, j + 1] = v Vd = np.empty_like(V) Vd[:, :-1] = np.diff(V, axis=1) / ds Vd[:, -1] = Vd[:, -2] return V, Vd band = (s > band_lo) & (s < s[-1] - band_hi) sub = np.flatnonzero(band)[:: max(int(band.sum()) // 140, 1)] rb = r[sub] def tightest(V): """for each family, the smallest hoop gap each adjacent pair ever reaches""" out = {} for f in (1.0, -1.0): k = np.flatnonzero(fam == f) o = k[np.argsort(np.mod(V[k, sub[0]], 2 * np.pi))] P = np.mod(V[o][:, sub], 2 * np.pi) d = (np.vstack([P[1:], P[:1] + 2 * np.pi]) - P) % (2 * np.pi) * rb out[f] = (o, d.min(1)) return out # start angles: two interleaved rings, then relaxed v0 = np.zeros(K) for f in (1.0, -1.0): k = np.flatnonzero(fam == f) v0[k] = 2 * np.pi * np.arange(len(k)) / len(k) + (0.0 if f > 0 else np.pi / len(k)) V, Vd = integrate(v0) eta = float(spec.get("relax", 0.35)) for it in range(int(spec.get("passes", 20))): tg = tightest(V) for f in (1.0, -1.0): o, q = tg[f] x = np.mod(V[o, 0], 2 * np.pi) gap = np.diff(np.append(x, x[0] + 2 * np.pi)) # start spacing, in order qb = max(float(np.mean(q)), 1e-6) gap *= np.exp(eta * (qb - q) / qb) # roomy pairs give, tight take gap *= 2 * np.pi / gap.sum() v0[o] = x[0] + np.concatenate([[0.0], np.cumsum(gap)[:-1]]) V, Vd = integrate(v0) SA = np.full(K, s[0]); SB = np.full(K, s[-1]) self._install(s, r, V, Vd, hw, SA, SB, band_lo, band_hi, beta, a_min, danger) P = np.sort(np.mod(V[:, sub], 2 * np.pi), axis=0) self.void = float((((np.vstack([P[1:], P[:1] + 2 * np.pi]) - P) * rb).max() - 2 * hw.mean())) assert (self.worst_run <= SLOT and self.merged <= MERGE and self.free_run <= FREE and self.void <= HOLE), ( f"flow did not settle: slot {self.worst_run:.1f}/{SLOT:.0f}, " f"merge {self.merged:.1f}/{MERGE:.0f}, free {self.free_run:.1f}/{FREE:.0f}, " f"void {self.void:.1f}/{HOLE:.0f} mm") class VeinLattice(StrandField): """Strands that fork and die, so how many there are is an OUTPUT, not a parameter. Every other wall here decides on K lines and then argues about where to put them. That is backwards for a bowl: the circumference doubles from foot to rim, so a fixed K is either crowded at the bottom or gaping at the top, and the phase lattice needed a whole special rule (branch only exactly on a crossing) to escape it. Here the count is not chosen at all. The solver is a feedback loop, not a search. March up the wall; every few millimetres measure the local hoop pitch; if it has opened past `pitch[1]`, FORK the strand beside the widest gap, and if it has closed under `pitch[0]`, END the strand in the tightest one. No objective function, no redrawing, no acceptance test -- just a controller holding one number steady while the geometry underneath it changes. What comes out is dendritic rather than woven: lines that begin somewhere, run, split, and stop. Both events are printable, and for different reasons, which is worth being precise about: * a fork starts the child AT its parent, so the crotch has solid directly beneath it. A branch that started anywhere else would be a hemisphere hanging in space, and the alpha audit would not catch it -- the audit measures edge inclination, and a cap's edges are perfectly steep. (This is the same rule the phase lattice's splits obey.) * a tip is the TOP of a strand, a dome, self-supporting by construction. Which is why strands may end anywhere but may only begin on something. """ def __init__(self, s, r, sig, K0, w, a_min, band_lo, band_hi, spec): a_min = np.asarray(a_min, float) * np.ones_like(s) sg = np.radians(np.clip(sig, np.minimum(SIG_MIN, a_min), 90.0)) beta = np.minimum(np.arccos(np.clip(np.sin(np.radians(a_min)) / np.sin(sg), -1, 1)), d2r(float(spec.get("beta", 90.0)))) cap = float(spec.get("cap_margin", 0.97)) * np.tan(beta) / r rng = np.random.default_rng(int(spec.get("seed", 1))) kinds = tuple(spec.get("shapes", SHAPES2)) p_lo, p_hi = spec.get("pitch", (4.5, 12.0)) # mm of hoop room per strand, held # p_hi must be at least twice p_lo or the controller fights itself: forking a gap of # p_hi leaves two of p_hi/2, and if that is under p_lo the birth is immediately marked # for death. At (5.5, 9.5) that produced 545 births, 457 deaths and 18 mm of fused run. assert p_hi >= 2 * p_lo, "fork and kill thresholds overlap: need pitch[1] >= 2 * pitch[0]" swing = tuple(spec.get("swing", (0.30, 0.80))) lean = tuple(spec.get("lean", (0.35, 0.85))) flip = float(spec.get("flip_fork", 0.72)) # a child that leans the other way wvar = float(spec.get("wvar", 1.4)) check = float(spec.get("check", 3.0)) # how often the controller looks, mm age = float(spec.get("min_age", 9.0)) # no stubs: a strand lives this long nmax = int(spec.get("max_strands", 400)) SLOT = float(spec.get("max_slot_run", 6.0)) MERGE = float(spec.get("max_merge_run", 16.0)) FREE = float(spec.get("max_free_run", 34.0)) HOLE = float(spec.get("max_hole", 26.0)) danger = float(spec.get("danger", 0.55)) smax = s[-1] j0 = int(np.argmax(s >= band_lo)); j1 = int(np.argmax(s >= smax - band_hi)) nsm = max(int(float(spec.get("smooth", 2.5)) / float(np.mean(np.diff(s)))), 1) box = np.ones(nsm) / nsm def newborn(fam, v, ja): # Round the corners. A sawtooth's apex is printable -- both sides of it obey the # slope rule -- but the field's level set is only PARALLEL to the centreline where # the slope is locally constant, and it is the level set the audit measures. At a # hard corner the edge bends ~3 deg more than the middle does, which is exactly the # two vertices that survived cap_margin. Smoothing the slope over a couple of # millimetres cannot break the cap (an average of things inside [-1,1] is inside it) # and is the same trick _sq already plays on the square wave. g = np.convolve(np.pad(_shape(str(rng.choice(kinds)), s, rng), nsm, mode="edge"), box, mode="same")[nsm:-nsm] aa = float(rng.uniform(*swing)); m = float(rng.uniform(*lean)) if m + aa > 1.0: aa = 1.0 - m return dict(fam=fam, v=v, ja=ja, jb=j1, g=g, m=m, a=aa, alone=0.0, hw=0.5 * w * float(rng.uniform(1.0, wvar)), vs=[v], vd=[]) live = [newborn(1.0 if i % 2 == 0 else -1.0, 2 * np.pi * i / K0, j0) for i in range(K0)] done = [] nxt = s[j0] + check for j in range(j0, j1): h = s[j + 1] - s[j] for b in live: d = b["fam"] * cap[j] * (b["m"] + b["a"] * b["g"][j]) b["vd"].append(d); b["v"] += h * d; b["vs"].append(b["v"]) if s[j + 1] >= nxt and j + 1 < j1: nxt += check # ---- the controller, and it has to be LOCAL. Judging the mean pitch does not # work: the circumference only ever grows, so a global rule sees "too roomy" # forever, forks forever and never ends anything -- the first version made 27 # births and 0 deaths. Looking at each GAP instead gives both, and it is what # makes the pattern dendritic: a gap that has opened gets a new branch grown # into it, and a pair that has closed loses one of the two. order = sorted(range(len(live)), key=lambda k: np.mod(live[k]["v"], 2 * np.pi)) x = np.array([np.mod(live[k]["v"], 2 * np.pi) for k in order]) gap = (np.append(x[1:], x[0] + 2 * np.pi) - x) * r[j + 1] for t in np.argsort(gap)[::-1]: # widest gaps first if gap[t] < p_hi or len(live) >= nmax: break L = live[order[t]]; R = live[order[(t + 1) % len(order)]] # Which side to fork from is not a coin toss. A child aims into the gap, so # forking left makes it lean +1 and forking right -1 -- and if that matches # its parent the two separate at only |dm| * cap, which at the foot is 11 mm # to clear one wire width. That single detail was the whole of a 12.2 mm # fused run in every configuration tried. So pick the side that puts the # child in OPPOSITION to its parent, and only fall back on leaning harder. if L["fam"] < 0: par, fam = L, 1.0 elif R["fam"] > 0: par, fam = R, -1.0 else: par, fam = (L, 1.0) if rng.random() < 0.5 else (R, -1.0) ch = newborn(fam, par["v"], j + 1) ch["hw"] = min(ch["hw"], par["hw"]) # its start cap must sit INSIDE the if fam == par["fam"]: # parent, or the rim of it is exposed ch["m"] = min(max(ch["m"], par["m"] + 0.45), 1.0 - ch["a"]) live.append(ch) # ---- a vein that anastomoses with nothing dies back. Without this the # growth happily leaves a strand running 66 mm touching nobody, which is a wire # with no brace on it; and unlike the other three failures it cannot be fixed by # spacing, because the strand is alone precisely BECAUSE the spacing is right. # Killing it is the honest repair and it costs nothing: the tip is a dome. near = (np.abs(x[:, None] - x[None, :]) * r[j + 1] <= np.array([live[k]["hw"] for k in order])[:, None] + np.array([live[k]["hw"] for k in order])[None, :]) np.fill_diagonal(near, False) kill = [] for u, k in enumerate(order): b = live[k] b["alone"] = 0.0 if near[u].any() else b["alone"] + check if b["alone"] > 0.5 * FREE and s[j + 1] - s[b["ja"]] >= age: kill.append(b) for t in np.argsort(gap): # tightest gaps first if gap[t] > p_lo or len(live) - len(kill) <= 10: break b = live[order[t]] # collect, THEN remove: removing if b in kill or s[j + 1] - s[b["ja"]] < age: continue # inside the loop kill.append(b) # reshuffles the indices `order` for b in kill: # is still holding b["jb"] = j + 1; done.append(b); live.remove(b) for b in live: b["jb"] = j1; done.append(b) K = len(done) assert K >= 8, "the controller starved the wall" V = np.zeros((K, len(s))); Vd = np.zeros((K, len(s))) SA = np.zeros(K); SB = np.zeros(K); hw = np.zeros(K) for i, b in enumerate(done): ja, jb = b["ja"], b["jb"] vs = np.array(b["vs"][: jb - ja + 1]); vd = np.array(b["vd"][: jb - ja]) V[i, ja:ja + len(vs)] = vs V[i, :ja] = vs[0]; V[i, ja + len(vs):] = vs[-1] Vd[i, ja:ja + len(vd)] = vd SA[i], SB[i], hw[i] = s[ja], s[jb], b["hw"] # a strand born at the very foot starts on the solid band; one born higher starts on its # parent. Either way nothing begins in mid-air, which is the only rule births obey. self._install(s, r, V, Vd, hw, SA, SB, band_lo, band_hi, beta, a_min, danger) self.born = int((SA > s[j0] + 1e-9).sum()) self.died = int((SB < s[j1] - 1e-9).sum()) m_ = (s > band_lo) & (s < smax - band_hi) sub = np.flatnonzero(m_)[:: max(int(m_.sum()) // 140, 1)] worst = 0.0 for j in sub: al = (s[j] >= SA) & (s[j] <= SB) if al.sum() < 2: worst = 1e9; break q = np.sort(np.mod(V[al, j], 2 * np.pi)) worst = max(worst, float(((np.append(q[1:], q[0] + 2 * np.pi) - q) * r[j]).max())) self.void = worst - 2 * hw.mean() assert (self.worst_run <= SLOT and self.merged <= MERGE and self.free_run <= FREE and self.void <= HOLE), ( f"growth did not settle: slot {self.worst_run:.1f}/{SLOT:.0f}, " f"merge {self.merged:.1f}/{MERGE:.0f}, free {self.free_run:.1f}/{FREE:.0f}, " f"void {self.void:.1f}/{HOLE:.0f} mm ({K} strands, {self.born} born, {self.died} ended)") class MosaicLattice: """Not strands at all. Nodes and struts -- an irregular polygonal membrane. Everything else in this file is a union of long continuous LINES, and that is why every one of them, however the lines are drawn, reads as weaving. A weave is what you get when the same curve runs the whole height of the wall. Cut the lines into short struts meeting at nodes and the character changes completely: the eye stops following threads and starts reading CELLS, so the object looks cracked, veined, leaded -- a membrane rather than a fabric. The cells are triangles, quads and pentagons of wildly different size, because the nodes are jittered in height as well as angle and each one keeps a random number of struts. The one rule is easier here than anywhere else in the file, because a strut is a straight segment: its inclination is fixed along its whole length, so it either passes or it does not, and an edge that fails is simply never offered. Only one extra rule is needed, and it is the same rule births obey everywhere here -- every node must be REACHED from below, or it is a dot printed on air. Rows are built upward and any node left unreached is wired to the nearest node beneath it that a legal strut can get to. The field is the union of capsules -- the true distance to each segment, not to its infinite line -- so |grad F| is exactly 1 along every strut and through every rounded end, which is what the boundary walk and the angle audit both stand on. Segments are short in s, so they are binned by height and a query only ever tests the few dozen that could possibly be nearest; without that this is a thousand-edge loop over a million points. """ def __init__(self, s, r, sig, w, a_min, band_lo, band_hi, spec): self.s, self.r, self.w = s, r, w self.band_lo, self.band_hi, self.smax = band_lo, band_hi, s[-1] self.ssplit, self.N = [], 0 a_min = np.asarray(a_min, float) * np.ones_like(s) self.a_min = a_min sg = np.radians(np.clip(sig, np.minimum(SIG_MIN, a_min), 90.0)) self.beta = np.minimum(np.arccos(np.clip(np.sin(np.radians(a_min)) / np.sin(sg), -1, 1)), d2r(float(spec.get("beta", 90.0)))) rng = np.random.default_rng(int(spec.get("seed", 1))) row = float(spec.get("row", 8.5)) # nominal height of a course of cells pitch = float(spec.get("pitch", 9.0)) # nominal hoop spacing of nodes hjit = float(spec.get("row_jitter", 0.55)) # course to course vjit = float(spec.get("node_jitter", 0.42)) # node to node WITHIN a course: this is ajit = float(spec.get("angle_jitter", 0.55)) # what stops it reading as courses # One width for every strut, on purpose. A strut's lower end is a round cap centred on # its node, and it is buried only if the strut arriving from below is at least as wide. # Equal widths make that automatic; varying them left a crescent of each cap exposed -- # 62 vertices of genuine overhang, which is what the audit found. Line-weight variety # would have to come from a taper along the strut, and a taper is not a capsule. wvar = float(spec.get("wvar", 1.0)) deg_w = np.array(spec.get("degree", (0.16, 0.62, 0.22)), float) # 1, 2 or 3 struts up deg_w = deg_w / deg_w.sum() margin = float(spec.get("margin", 0.94)) # of the angle budget, never more lo, hi = band_lo, self.smax - band_hi # ---- courses. The first sits exactly on the foot band and the last exactly on the rim # band, so the membrane is anchored at both ends and carries the rim. hs = [lo] while hs[-1] + row * 0.6 < hi: hs.append(hs[-1] + row * max(1.0 + hjit * (2 * rng.random() - 1), 0.55)) hs[-1] = hi hs = np.array(hs) gapk = np.minimum(np.diff(hs, prepend=hs[0] - (hs[1] - hs[0])), np.diff(hs, append=hs[-1] + (hs[-1] - hs[-2]))) nodes_s, nodes_v, rows = [], [], [] for k, h in enumerate(hs): rk = float(np.interp(h, s, r)) # ---- the node pitch is NOT free, and this is the whole geometry of the thing. A # strut may only lean tan(beta) sideways per mm of rise, and at the foot beta is 17 # deg, so over one course it can travel 2.6 mm across -- while nodes 9 mm apart put # the nearest one 4.5 mm away. There is then no legal strut at all, which is what # the first build hit. So the pitch is read off the budget, course by course: fine # cells low down where the wall leans, coarse ones near the rim where it stands up. bk = float(self.beta[np.argmin(np.abs(s - h))]) # 1.15, not 1.8: the nominal course gap is not what a strut actually gets. Nodes # are jittered in height too, so a node pushed up meeting one pushed down leaves as # little as two thirds of it, and a pitch sized on the nominal gap leaves those two # unreachable -- which is what "could not be reached from below" was. pk = min(pitch, 1.15 * gapk[k] * math.tan(bk)) n = max(int(round(2 * np.pi * rk / pk)), 6) a = 2 * np.pi * (np.arange(n) + ajit * (rng.random(n) - 0.5)) / n + 2 * np.pi * rng.random() if k == 0 or k == len(hs) - 1: sn = np.full(n, h) # anchored courses stay flat else: sn = h + min(vjit * row, 0.34 * gapk[k]) * (rng.random(n) - 0.5) sn = np.clip(sn, lo + 0.2, hi - 0.2) rows.append(np.arange(len(nodes_s), len(nodes_s) + n)) nodes_s.extend(sn); nodes_v.extend(np.mod(a, 2 * np.pi)) NS = np.array(nodes_s); NV = np.array(nodes_v) def legal(i, j): """can a strut run from node i up to node j? One straight segment, one angle.""" dsv = NS[j] - NS[i] if dsv <= 0.35: return False rm = float(np.interp(0.5 * (NS[i] + NS[j]), s, r)) du = abs(rm * wrap(NV[j] - NV[i])) m0 = float(self.beta[(s >= min(NS[i], NS[j])) & (s <= max(NS[i], NS[j]))].min()) return du <= margin * math.tan(m0) * dsv E = [] for k in range(len(rows) - 1): up = rows[k + 1] reached = np.zeros(len(up), bool) for i in rows[k]: cand = up[np.argsort(np.abs(wrap(NV[up] - NV[i])))][:5] cand = [j for j in cand if legal(i, j)] if not cand: continue d = 1 + int(rng.choice(3, p=deg_w)) for j in cand[:d]: E.append((i, j)); reached[np.searchsorted(up, j)] = True for t in np.flatnonzero(~reached): # nothing may be printed on air j = up[t] pool = np.concatenate([rows[k]] + ([rows[k - 1]] if k else [])) dn = pool[np.argsort(np.abs(wrap(NV[pool] - NV[j])))] ok = [i for i in dn if legal(i, j)] assert ok, "a node could not be reached from below: raise row or lower pitch" E.append((ok[0], j)); reached[t] = True E = np.array(sorted(set(E))) assert len(E), "no struts" self.EA, self.EB = E[:, 0], E[:, 1] self.ehw = 0.5 * w * rng.uniform(1.0, wvar, len(E)) self.NS, self.NV = NS, NV # every strut's inclination, measured not assumed rm = np.interp(0.5 * (NS[self.EA] + NS[self.EB]), s, r) self.edu = rm * wrap(NV[self.EB] - NV[self.EA]) self.eds = NS[self.EB] - NS[self.EA] self.ebeta = np.degrees(np.arctan2(np.abs(self.edu), self.eds)) # ---- bin the struts by height; a query tests only the bins it lands in self.bw = max(row, 4.0) self.b0 = float(s[0]) nb = int((s[-1] - self.b0) / self.bw) + 2 self.bins = [[] for _ in range(nb)] for e in range(len(E)): a_ = min(NS[self.EA[e]], NS[self.EB[e]]) - self.ehw[e] b_ = max(NS[self.EA[e]], NS[self.EB[e]]) + self.ehw[e] for q in range(max(int((a_ - self.b0) / self.bw), 0), min(int((b_ - self.b0) / self.bw) + 1, nb)): self.bins[q].append(e) self.bins = [np.array(b, int) for b in self.bins] self.cells = len(E) - len(NS) + 1 # Euler, on a cylinder def level(self, s): return np.zeros_like(s, int) def fields(self, s, v): s = np.asarray(s, float); v = np.asarray(v, float) r = np.interp(s, self.s, self.r) F = np.full(s.shape, -1e9, float) b = np.clip(((s - self.b0) / self.bw).astype(int), 0, len(self.bins) - 1) for q in np.unique(b): e = self.bins[q] if not len(e): continue m = b == q ss, vv, rr = s[m], v[m], r[m] best = np.full(ss.shape, -1e9, float) for k in e: ps = self.NS[self.EA[k]] - ss # A relative to the query point, pu = rr * wrap(self.NV[self.EA[k]] - vv) # in the local (s, r v) plane dsq = self.NS[self.EB[k]] - self.NS[self.EA[k]] duq = rr * wrap(self.NV[self.EB[k]] - self.NV[self.EA[k]]) t = np.clip(-(ps * dsq + pu * duq) / (dsq * dsq + duq * duq), 0.0, 1.0) best = np.maximum(best, self.ehw[k] - np.hypot(ps + t * dsq, pu + t * duq)) F[m] = best band = np.maximum(self.band_lo - s, s - (self.smax - self.band_hi)) return F, band def F(self, s, v): a, b = self.fields(s, v); return np.maximum(a, b) def pitch_report(self): m = (self.s > self.band_lo) & (self.s < self.smax - self.band_hi) ss = self.s[m][:: max(len(self.s[m]) // 90, 1)] vv = np.linspace(0, 2 * np.pi, 2000, endpoint=False) SS, VV = np.meshgrid(ss, vv, indexing="ij") D = -self.F(SS.ravel(), VV.ravel()).reshape(SS.shape) c = D[1:-1, :]; pk = c > 0 for di in (-1, 0, 1): for dj in (-1, 0, 1): if di or dj: pk &= c >= np.roll(np.roll(D, -di, 0), -dj, 1)[1:-1, :] d = 2 * c[pk] if not len(d): return 0.0, 0.0, self.ebeta.min(), self.ebeta.max() return float(np.percentile(d, 1)), float(d.max()), self.ebeta.min(), self.ebeta.max() class FloorLattice: """Concentric rings + radial spokes on a FLAT floor that lies on the build plate. The alpha rule does not apply here and is not a loophole: every strut is a bar sitting on the bed from layer one, so there is no unsupported face anywhere in the region. Spokes double outward like the wall's strands; a doubling radius is snapped onto a ring so the new spokes are born on one instead of starting between two. Same interface as Lattice. """ def __init__(self, s, r, N, w, splits, ring_pitch, phase=0.0, band_lo=7.0, band_hi=3.0): self.s, self.r, self.N, self.w = s, r, N, w self.band_lo, self.band_hi, self.smax = band_lo, band_hi, s[-1] self.p = ring_pitch self.ph = phase * 2 * np.pi / N self.beta = np.zeros_like(r) # spokes and rings, no helix self.anchor = self.smax - band_hi # a ring lands on the outer solid band self.ssplit = [] for rs in splits: if not (r.min() < rs < r.max()): continue k = round((self.anchor - rs) / self.p) self.ssplit.append(float(self.anchor - k * self.p)) def level(self, s): return sum((s >= ss).astype(int) for ss in self.ssplit) if self.ssplit else np.zeros_like(s, int) def fields(self, s, v): """(lattice field, band field) at local (s, v). On a flat floor s is the radius.""" r = np.interp(s, self.s, self.r) M = self.N * 2 ** self.level(s) d = r - self.anchor f_ring = self.w / 2 - np.abs(d - self.p * np.round(d / self.p)) f_spoke = self.w / 2 - r * np.abs(wrap(M * (v + self.ph))) / M band = np.maximum(self.band_lo - s, s - (self.smax - self.band_hi)) return np.maximum(f_ring, f_spoke), band def F(self, s, v): a, b = self.fields(s, v); return np.maximum(a, b) def pitch_report(self): M = self.N * 2 ** self.level(self.s) hole = 2 * np.pi * self.r / M - self.w m = (self.s > self.band_lo) & (self.s < self.smax - self.band_hi) return min(hole[m].min(), self.p - self.w), max(hole[m].max(), self.p - self.w), 0.0, 0.0 class FlowerLattice: """Flower of Life on the flat floor: circle outlines of radius R centred on a triangular lattice of spacing R, so every circle passes through its six neighbours' centres. F = max over centres of (hw - | |p - c| - R |) is a true distance field to the union of those outlines, so |grad F| = 1 and the boundary walk and audit work unchanged. Only the centres within two lattice steps can carry a circle passing near a point, so the max runs over a 5x5 block found by rounding in the lattice basis - 25 terms instead of every centre. Every circle cuts its neighbours, so the figure is one connected piece with no free ends, and centres are generated past the rim so the outermost circles run into the solid band. Sacred geometry can live here and nowhere else on the bowl: this region is flat on the plate, so the 30-off-vertical rule has nothing to say about it. """ def __init__(self, s, r, R, w, band_lo, band_hi, stroke=None, circles=()): self.s, self.r, self.w, self.R = s, r, w, float(R) self.band_lo, self.band_hi, self.smax = band_lo, band_hi, s[-1] self.circles = [float(c) for c in circles] # extra concentric rings, mm self.beta = np.zeros_like(r) self.ssplit = [] m_ = stroke or {} self.mA, self.mq, self.mpsi = float(m_.get("A", 0.0)), int(m_.get("q", 0)), float(m_.get("psi", 0.0)) assert w >= 0.8, f"strut {w:.2f} mm is under two extrusion lines" def level(self, s): return np.zeros_like(s, int) def fields(self, s, v): rr = np.interp(s, self.s, self.r) x, y = rr * np.cos(v), rr * np.sin(v) R = self.R hw = 0.5 * self.w * (1.0 + self.mA * (1.0 + np.sin(self.mq * v + self.mpsi * s))) # lattice basis e1 = (R, 0), e2 = (R/2, R sqrt3/2); invert to get lattice coordinates sq3 = math.sqrt(3.0) u = x / R - y / (R * sq3) t = 2.0 * y / (R * sq3) u0, t0 = np.round(u), np.round(t) best = np.full_like(s, 1e9) for du in (-2, -1, 0, 1, 2): for dt in (-2, -1, 0, 1, 2): cu, ct = u0 + du, t0 + dt cx = R * cu + 0.5 * R * ct cy = 0.5 * sq3 * R * ct best = np.minimum(best, np.abs(np.hypot(x - cx, y - cy) - R)) for c in self.circles: best = np.minimum(best, np.abs(rr - c)) band = np.maximum(self.band_lo - s, s - (self.smax - self.band_hi)) return hw - best, band def F(self, s, v): a, b = self.fields(s, v); return np.maximum(a, b) def pitch_report(self): """Hole diameters measured at the local maxima of distance-to-material (not min/max of a pitch - a flower has no single pitch).""" ns, nv = 360, 720 ss = np.linspace(self.band_lo, self.smax - self.band_hi, ns) vv = np.arange(nv) * 2 * np.pi / nv S, V = np.meshgrid(ss, vv, indexing="ij") f, _ = self.fields(S.ravel(), V.ravel()) d = (-f).reshape(S.shape) # distance to material where positive c = d[1:-1, :] pk = ((c > d[:-2, :]) & (c > d[2:, :]) & (c > np.roll(c, 1, 1)) & (c > np.roll(c, -1, 1)) & (c > 0)) h = 2.0 * c[pk] return (h.min(), h.max(), 0.0, 0.0) if h.size else (0.0, 0.0, 0.0, 0.0) def strut_angles(lat, s, v, sig): """alpha (deg) of the strut edge through each (s, v); nan on creases and band edges.""" h = 0.02; r = np.interp(s, lat.s, lat.r) Fl, Fb = lat.fields(s, v) Fs = (lat.F(s + h, v) - lat.F(s - h, v)) / (2 * h) Fu = (lat.F(s, v + h / r) - lat.F(s, v - h / r)) / (2 * h) gm = np.hypot(Fs, Fu) beta = np.arctan2(np.abs(Fs), np.abs(Fu)) alpha = np.degrees(np.arcsin(np.clip(np.cos(beta) * np.sin(np.radians(sig)), -1, 1))) # ---- and only where the solid lies ABOVE the boundary. grad F points into the material, # so Fs > 0 means the strut is advancing upward across this edge: that edge is the leading # one, the one that steps sideways over air each layer, and it is what the rule is about. # Where Fs < 0 the material is retreating -- the top of a strut, or the dome that closes a # strand that ends -- and there is nothing underneath it to be unsupported. This is not a # weakening: a strut's two edges are parallel, so exactly one of them has Fs > 0 and carries # the same alpha, and every strut is still fully constrained by that one. It matters only # now that strands may END: a cap's top edge runs dead horizontal and read alpha 0.2 deg, # which is how coral came out 3.43% "under" while being perfectly printable. A cap at the # BOTTOM of a strand still fails, correctly -- that one really is a hemisphere on air. ok = (gm > 0.95) & (Fl > Fb + 0.05) & (Fs > 0) if hasattr(lat, "cap_mask"): ok &= ~lat.cap_mask(s, v) return np.where(ok, alpha, np.nan), Fb >= Fl, gm # ----------------------------------------------------------------------------- build def build(name, b): t0 = time.time() (s, r, z, th, tag), regions = profile(b) a_spec = b.get("a_min", A_MIN) def amin_of(sl): """the limit at local height sl: a pair means foot -> rim, a number means everywhere""" if isinstance(a_spec, (tuple, list)): lo, hi = a_spec return lo + (hi - lo) * np.clip(sl / max(sl.max(), 1e-9), 0, 1) return np.full_like(sl, float(a_spec)) segs = 300 if FAST else int(b.get("segs", SEGS)) cell = float(b.get("cell", CELL)) S = s[-1] sig_all = np.degrees(np.arctan2(np.abs(np.sin(np.radians(th))), np.abs(np.cos(np.radians(th))))) rings = int(round(S / (cell * (2.5 if FAST else 1.0)))) ss = np.linspace(0, S, rings + 1) sc = 0.5 * (ss[1:] + ss[:-1]) vv = np.arange(segs) * 2 * np.pi / segs dv = 2 * np.pi / segs # lattices per region, in local upward coordinates lats = {} for tg, up in regions.items(): m = tag == tg sa, sb = s[m].min(), s[m].max() sl = (s[m] - sa) if up else (sb - s[m]) order = np.argsort(sl) if tg == "floor": fm = b.get("floor_mesh", {}) fs = {**b, **fm} hub = fs.get("band_lo", 7.0) if fs.get("style") == "flower": lat = FlowerLattice(sl[order], r[m][order], fs["R"], fs.get("w", WALL), hub, fs.get("band_hi", 3.0), fm.get("stroke"), fs.get("circles", ())) elif fs.get("style") == "spiral": # Two counter-turning logarithmic spiral families: the same net as a wall, with # s = r and sigma pinned to 90 so a_min never bites. It cannot bite: the region # is flat on the plate. beta can therefore run up to a real swirl, and the # branching stays exact, so no spiral ever starts off a crossing. lat = Lattice(sl[order], np.maximum(r[m][order], hub), np.full(int(m.sum()), 90.0), fs["N"], fs.get("w", WALL), fs.get("beta", 62.0), fs.get("splits", []), fs.get("phase", 0.0), hub, fs.get("band_hi", 3.0), a_min=0.0, stroke=fm.get("stroke"), sway=fm.get("sway"), jitter=fm.get("jitter")) # never the wall's elif fs.get("style") == "free": # Free strands on the FLOOR. Same solver, and the one rule simply does not # apply: sigma is pinned to 90 and a_min to 0, because every strut in the region # is a bar lying on the plate from layer one. So beta is limited by nothing but # the cap asked for in the spec, and a strand may curl as hard as it likes -- # which is the point. The wall gets the chaos it can afford; the floor gets all # of it. r is clamped at the hub so cap = 0.97 tan(beta) / r stays finite there. n_ = int(m.sum()) lat = FreeLattice(sl[order], np.maximum(r[m][order], hub), np.full(n_, 90.0), fs["N"], fs.get("w", WALL), np.zeros(n_), hub, fs.get("band_hi", 3.0), fs, on_bed=True) else: lat = FloorLattice(sl[order], r[m][order], fs["N"], fs.get("w", WALL), fs.get("splits", []), fs.get("ring_pitch", 6.6), fs.get("phase", 0.0), hub, fs.get("band_hi", 3.0)) lat.on_bed = True else: spec = b if tg == "wall" else {**b, **b.get("inner", {})} st = spec.get("style") if st in ("free", "flow", "vein", "mosaic"): args = (sl[order], r[m][order], sig_all[m][order]) rest = (spec.get("w", WALL), amin_of(sl[order]), spec.get("band_lo", 2.0), spec.get("band_hi", 4.0), spec) if st == "mosaic": lat = MosaicLattice(*args, *rest) else: cls = {"free": FreeLattice, "flow": FlowLattice, "vein": VeinLattice}[st] lat = cls(*args, spec["N"], *rest) lats[tg] = (lat, sa, sb, up) continue lat = Lattice(sl[order], r[m][order], sig_all[m][order], spec["N"], spec.get("w", WALL), spec.get("beta", 42), spec.get("splits", []), spec.get("phase", 0.0), spec.get("band_lo", 2.0), spec.get("band_hi", 4.0), amin_of(sl[order]), spec.get("wave"), spec.get("stroke"), spec.get("sway"), spec.get("ogive"), spec.get("mullion"), spec.get("jitter")) lats[tg] = (lat, sa, sb, up) def loc(tg, sg): lat, sa, sb, up = lats[tg]; return (sg - sa) if up else (sb - sg) def field(sg, v): """global field: +1 in solid regions, lattice field inside lattice regions""" out = np.ones_like(sg) for tg, (lat, sa, sb, up) in lats.items(): m = (sg >= sa) & (sg <= sb) if m.any(): out[m] = lat.F(loc(tg, sg[m]), v[m]) return out hole = {tg: lat.pitch_report() for tg, (lat, *_) in lats.items()} dia = 2 * r.max() + WALL; hgt = z.max() + WALL / 2 if b["kind"] == "basket": solid_r = lats["floor"][0].band_lo # only the hub at the centre is solid else: solid_r = max(r[tag == "floor"].max(), r[np.isin(tag, ["fillet", "ifillet"])].max() if np.isin(tag, ["fillet", "ifillet"]).any() else 0) print(f"[{name}] dia {dia:.1f} x h {hgt:.1f} mm, solid dish r {solid_r:.1f} ({solid_r / (dia / 2) * 100:.0f}% of the width), " + ", ".join(f"{tg}: hole {a:.1f}-{c:.1f} mm, beta {d:.0f}-{e:.0f} deg, splits at r " + "/".join(f"{np.interp(x, lats[tg][0].s, lats[tg][0].r):.0f}" for x in lats[tg][0].ssplit) for tg, (a, c, d, e) in hole.items())) if DIMS: return None # ---- cell mask SC, VC = np.meshgrid(sc, vv + dv / 2, indexing="ij") Fc = field(SC.ravel(), VC.ravel()).reshape(SC.shape) keep = Fc > 0 # A one-cell gap would snap shut: the vertices on both sides walk onto the same contour and # meet as a bow-tie (non-manifold vertex). Fill it - it was going to be a sliver anyway. # Two cells touching only at a corner are the same vertex problem: fill the better of the two. for _ in range(8): k = keep gs = ~k[1:-1] & k[:-2] & k[2:]; keep[1:-1] |= gs gv = ~k & np.roll(k, 1, 1) & np.roll(k, -1, 1); keep |= gv k = keep; kr = np.roll(k, -1, 1); fr = np.roll(Fc, -1, 1) a = k[:-1] & kr[1:] & ~k[1:] & ~kr[:-1] # (i,j) & (i+1,j+1) kept, (i+1,j) & (i,j+1) not bb = kr[:-1] & k[1:] & ~k[:-1] & ~kr[1:] # (i,j+1) & (i+1,j) kept, (i,j) & (i+1,j+1) not if not (a.any() or bb.any()): break i, j = np.nonzero(a) pick_down = Fc[i + 1, j] >= fr[i, j] # fill (i+1,j) or (i,j+1), whichever is closer to a strut keep[i[pick_down] + 1, j[pick_down]] = True; keep[i[~pick_down], (j[~pick_down] + 1) % segs] = True i, j = np.nonzero(bb) pick_here = Fc[i, j] >= fr[i + 1, j] keep[i[pick_here], j[pick_here]] = True; keep[i[~pick_here] + 1, (j[~pick_here] + 1) % segs] = True # ---- measured open area per lattice region (cell mask, weighted by cell area r*dv*ds) rc_ = np.interp(sc, s, r) openf = {} for tg, (lat, sa, sb, up) in lats.items(): lo = sa + (lat.band_lo if up else lat.band_hi) hi = sb - (lat.band_hi if up else lat.band_lo) mm_ = (sc > lo) & (sc < hi) if mm_.any(): wgt = np.repeat(rc_[mm_][:, None], segs, 1) openf[tg] = 1.0 - (keep[mm_] * wgt).sum() / wgt.sum() print(f"[{name}] open area " + ", ".join(f"{tg} {v * 100:.0f}%" for tg, v in openf.items())) # ---- vertices: (rings+1) x SEGS, used / boundary inc = np.zeros((rings + 1, segs), int); drop = np.zeros((rings + 1, segs), int) for di in (0, 1): for dj in (0, 1): k = np.roll(keep, dj, 1) # cell (i, j-dj) seen from vertex (i+di, j) inc[di:di + rings] += k; drop[di:di + rings] += ~k used = inc > 0 bnd = used & (drop > 0) VS, VV = np.meshgrid(ss, vv, indexing="ij") VS = VS.copy(); VV = VV.copy() # ---- walk boundary vertices onto the exact strut edge (Newton step along the field gradient) lat_mask = np.zeros_like(bnd) for tg, (lat, sa, sb, up) in lats.items(): lat_mask |= (VS > sa) & (VS < sb) snap = bnd & lat_mask ds = S / rings for it in range(5): sg, v = VS[snap], VV[snap] rr = np.interp(sg, s, r) h = 0.02 F0 = field(sg, v) Fs = (field(sg + h, v) - field(sg - h, v)) / (2 * h) Fu = (field(sg, v + h / rr) - field(sg, v - h / rr)) / (2 * h) g2 = Fs ** 2 + Fu ** 2 + 1e-12 dS, dU = -F0 * Fs / g2, -F0 * Fu / g2 # Clamp each component against its OWN cell size, not both against the smaller of the # two. What the clamp is for is stopping a vertex crossing into a neighbouring cell, # and that is a per-axis condition. Binding the radial step to the hoop cell matters # nowhere on a wall (the two are within 20% of each other there) and is crippling on a # mesh floor, where segs is fixed while r runs 7 -> 72: at mid radius the hoop cell is # 0.17 mm against 0.34 radial, the walk cannot reach a contour half a ring away, and # the strut edges come out combed instead of straight. sc_ = np.minimum(1.0, np.minimum(0.45 * ds / np.maximum(np.abs(dS), 1e-9), 0.45 * rr * dv / np.maximum(np.abs(dU), 1e-9))) VS[snap] = np.clip(sg + dS * sc_, 0, S); VV[snap] = v + dU * sc_ / rr # ---- strut angle audit on the snapped boundary report = [] for tg, (lat, sa, sb, up) in lats.items(): m = snap & (VS > sa) & (VS < sb) sg, v = VS[m], VV[m] if getattr(lat, "on_bed", False): zf = z[tag == tg] assert zf.max() - zf.min() < 1e-9 and abs(zf[0] - WALL / 2) < 1e-9, \ "a mesh floor is only printable flat on the bed" report.append(f"{tg}: flat on the bed, z 0.0-{WALL:.1f} mm, {m.sum()} edge verts, no overhang to audit") continue sigv = np.interp(sg, s, sig_all) alpha, isband, gmv = strut_angles(lat, loc(tg, sg), v, sigv) measured = ~np.isnan(alpha) # NB not `keep`: that is the cell mask, needed below a = alpha[measured] amv = np.interp(loc(tg, sg), lat.s, lat.a_min)[measured] nbad = int((a < amv - 0.5).sum()) under = nbad / len(a) * 100 if len(a) else 0 if os.environ.get("DEBUG"): lim = np.interp(loc(tg, sg), lat.s, lat.a_min) bad = np.nonzero(alpha < lim - 0.5)[0]; bad = bad[np.argsort(alpha[bad])] sl = loc(tg, sg) for i in bad[:12]: near = min([abs(sl[i] - x) for x in lat.ssplit] or [99]) print(f" bad {tg} alpha {alpha[i]:.1f} s' {sl[i]:.1f} r {np.interp(sg[i], s, r):.1f} sigma {sigv[i]:.1f} " f"split-dist {near:.1f} band-dist {min(sl[i], lat.smax - sl[i]):.1f}") report.append(f"{tg}: strut alpha min {a.min():.1f} / p1 {np.percentile(a, 1):.1f} / median {np.median(a):.1f} deg, " f"{under:.2f}% ({nbad} verts) under the limit ({lat.a_min.min():.0f} at the foot to " f"{lat.a_min.max():.0f} at the rim) ({len(a)} of {len(alpha)} edge verts audited, " f"{isband.sum()} on band edges, |grad F| {np.percentile(gmv, 1):.2f}-{np.percentile(gmv, 99):.2f})") print(f"[{name}] " + " | ".join(report)) # ---- mesh rr = np.interp(VS, s, r); zz = np.interp(VS, s, z) X, Y = rr * np.cos(VV), rr * np.sin(VV) idx = -np.ones((rings + 1, segs), int); idx[used] = np.arange(used.sum()) verts = np.stack([X[used], Y[used], zz[used]], 1) ci, cj = np.nonzero(keep) faces = np.stack([idx[ci, cj], idx[ci, (cj + 1) % segs], idx[ci + 1, (cj + 1) % segs], idx[ci + 1, cj]], 1) me = bpy.data.meshes.new(name) me.from_pydata([tuple(v) for v in verts], [], [tuple(f) for f in faces]) ob = bpy.data.objects.new(name, me); bpy.context.scene.collection.objects.link(ob) bm = bmesh.new(); bm.from_mesh(me) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-6) # the pole ring collapses to one vertex; nothing else may merge bmesh.ops.dissolve_degenerate(bm, dist=1e-4, edges=bm.edges) # (a bigger tolerance here merges vertices into bow-ties) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(me); bm.free() for o in bpy.context.scene.objects: o.select_set(o is ob) bpy.context.view_layer.objects.active = ob mod = ob.modifiers.new("solid", "SOLIDIFY") mod.thickness = WALL; mod.offset = 0.0; mod.use_even_offset = False; mod.use_rim = True; mod.use_quality_normals = True bpy.ops.object.modifier_apply(modifier=mod.name) # every vertex of the solid must lie within half a wall of the profile - a spike here is a # Solidify vertex thrown out along a degenerate normal, and it would print as a whisker P = np.array([(x, y) for x, y in zip(r, z)])[::5] V = np.array([v.co[:] for v in me.vertices]); VR = np.hypot(V[:, 0], V[:, 1]) worst = 0.0 for i in range(0, len(V), 20000): d = np.hypot(VR[i:i + 20000, None] - P[None, :, 0], V[i:i + 20000, 2, None] - P[None, :, 1]).min(1) worst = max(worst, d.max()) assert worst < WALL / 2 + 1.0, f"vertex {worst:.1f} mm off the profile surface" n_solid = len(me.polygons) bm = bmesh.new(); bm.from_mesh(me); vol = bm.calc_volume(signed=True); bm.free() grams = abs(vol) * PLA def n_tris(o): return sum(len(p.vertices) - 2 for p in o.data.polygons) def nonmanifold(o): bm = bmesh.new(); bm.from_mesh(o.data); n = sum(1 for e in bm.edges if len(e.link_faces) != 2); bm.free(); return n def decimate(o, target): tris = n_tris(o) if tris <= target: return tris bpy.context.view_layer.objects.active = o mod = o.modifiers.new("dec", "DECIMATE"); mod.decimate_type = "COLLAPSE"; mod.ratio = target / tris mod.use_collapse_triangulate = True bpy.ops.object.modifier_apply(modifier=mod.name) return n_tris(o) def copy_of(o): c = o.copy(); c.data = o.data.copy(); bpy.context.scene.collection.objects.link(c); return c def select_only(o): for x in bpy.context.scene.objects: x.select_set(x is o) bpy.context.view_layer.objects.active = o assert nonmanifold(ob) == 0, "solidified strip is not manifold" # STL: decimate, but never ship a mesh the collapse has folded - fall back to the full one stl = copy_of(ob); t_stl = decimate(stl, STL_TRIS); note = "" if nonmanifold(stl): bpy.data.objects.remove(stl); stl = copy_of(ob); t_stl = n_tris(stl); note = " (decimation folded the mesh: full-res STL)" select_only(stl) bpy.ops.wm.stl_export(filepath=os.path.join(OUT, f"{name}.stl"), export_selected_objects=True, ascii_format=False) bpy.data.objects.remove(stl) # GLB: decimate harder, then share vertices across smooth faces so the file is small; edges # sharper than 30 deg stay hard so the struts keep their corners glb = copy_of(ob); t_glb = decimate(glb, int(b.get("glb_tris", GLB_TRIS))) bm = bmesh.new(); bm.from_mesh(glb.data); bm.normal_update() for e in bm.edges: if len(e.link_faces) == 2: e.smooth = e.calc_face_angle(0.0) < math.radians(30) for f in bm.faces: f.smooth = True bm.to_mesh(glb.data); bm.free() select_only(glb) bpy.ops.export_scene.gltf(filepath=os.path.join(OUT, f"{name}.glb"), export_format="GLB", use_selection=True, export_draco_mesh_compression_enable=bool(b.get("draco", False))) bpy.data.objects.remove(glb) bpy.data.objects.remove(ob); bpy.data.meshes.remove(me) print(f"[{name}] {keep.sum()} cells kept of {keep.size}, {n_solid} faces after solidify, " f"stl {t_stl} tris{note}, glb {t_glb} tris, {grams:.0f} g, {time.time() - t0:.0f} s") return {"name": name, "dia_mm": round(dia, 1), "height_mm": round(hgt, 1), "grams": round(grams), "faces": t_glb, "solid_r_mm": round(solid_r, 1), "kind": b["kind"]} # ----------------------------------------------------------------------------- bowls # s0/s1: wall slope at foot and rim (deg above horizontal), r_rim: outer wall radius at the rim. # N: strand count per family at the foot; it doubles at every radius in `splits`. SINGLE = dict(kind="single", floor=24.0, fillet=10.0, s0=44.0, s1=60.0, r_rim=100.0) DOUBLE = dict(kind="double", foot=38.0, gap=7.5, fillet=8.0, s0=45.0, s1=61.0, r_rim=100.0) # basket: "half cylinder" - flat mesh floor on the bed, wall never more than 30 deg off # vertical, so a_min = 60 and beta is budgeted down to acos(sin 60 / sin sigma) (30 deg on a # vertical wall, 18 deg at sigma 66). Constant radius means the hoop pitch never grows, so # there is nothing to branch: no splits on the wall. wall = [(length mm, end slope deg)]. BASKET = dict(kind="basket", a_min=60.0, beta=30.0, corner_r=1.2, band_lo=3.0, band_hi=3.5, floor_mesh=dict(N=12, ring_pitch=6.6, splits=[14, 28, 54], band_lo=7.5, band_hi=3.0)) # Bed-filling baskets: waved net, brush-weight strokes, swirling spiral floor. # # What the 30-off-vertical rule allows, and what it does not. s0/s1 sit 21-24 deg off # vertical, because at exactly 30 off (sigma 60) the budget is beta = 0 and no crossing net # can exist - the wall would be bare verticals. Everything the strands do then shares one # budget, |dT/ds| r / |N -+ dT/dv| <= tan beta: # q (lobes around) is cheap - it only squeezes the hoop pitch, by (N - B q) / N, # and it shifts strands B r / N mm sideways, so it is the visible one; # psi (lobes rotating with height) is what actually CURVES a strand, and it is dear: # it spends B psi of the budget, and what is left sets how often the # two families cross, so overspending here stretches cells into slots; # stroke weight is free. It never moves a centreline, only fattens it, so the whole # brush-stroke look costs no angle at all. # No splits on a waved wall: a split is only safe where the new strand is born on a crossing, # and q moves the crossings with v. r only spans 1.6x here, so nothing needs to branch. VG = dict(kind="basket", a_min=60.0, beta=90.0, corner_r=1.2, w=1.3, band_lo=3.0, band_hi=3.5, floor_r=62.0, r_rim=102.0, s0=69.0, s1=66.0, N=100, segs=1500, cell=0.45, glb_tris=130_000, draco=True) # A shaped wall, and an angle rule that breathes with height. # Up to here every bowl was a straight cone under one flat limit: no strut more than 30 deg # off vertical anywhere. That is the strictest reading of the rule and it costs most exactly # where the bowl can least afford it -- low down, where the radius is small, the hoop pitch is # short and the holes are therefore tightest. Now the wall leans 42 deg off vertical at the # foot and stands up to 20 deg off vertical at the rim, and the strut limit rides with it: # 44 deg above horizontal at the foot easing to 60 deg (30 off vertical) at the rim. # A strut at 44 deg steps 0.2 / tan 44 = 0.21 mm sideways per layer, half a line width and # well inside this project's own long-standing 36 deg floor, so none of it is a gamble; it # simply spends the slack where the geometry is worst. The pay-off is lean: beta 23 deg at the # foot where a 70 deg wall under a flat 60 deg limit would have allowed 20 deg, and much more # room for the jitter to spend. That is what buys 66-88 strands of 0.8 mm wire instead of 96 # of 0.9, which is where the bigger holes come from. # sigma is checked against SIG_MIN and the audit is run against the ramped limit per vertex, # so nothing here is taken on trust: the build still reports 0.00% under. CHAOS = dict(kind="basket", a_min=(44.0, 60.0), beta=90.0, corner_r=1.2, w=0.8, band_lo=3.5, band_hi=5.0, floor_r=54.0, r_rim=102.0, s0=48.0, s1=70.0, # 0.8 mm wire is the thinnest here yet, and the grid has to resolve it: at # segs 1800 the hoop spacing at the rim is 0.36 mm, barely two cells across a # strut, and a strut that thin in cells snaps shut into a bow-tie somewhere. # 2200 x 0.34 puts ~2.7 cells across it in both directions. segs=2200, cell=0.34, glb_tris=150_000, draco=True) # Free strands: the wall net is no longer a perturbed helix field but K independent curves, # each with its own equation. See FreeLattice. a_min still ramps 44 -> 60 with height and the # shaped wall is unchanged; only the fill is different. FREE = dict(kind="basket", a_min=(44.0, 60.0), beta=90.0, corner_r=1.2, w=0.8, band_lo=3.5, band_hi=5.0, floor_r=54.0, r_rim=102.0, s0=48.0, s1=70.0, segs=2200, cell=0.34, glb_tris=150_000, draco=True, style="free") # Round two. Same solver, three things it could not do before: a silhouette other than # thicket's, an angle rule that runs the other way, and strands that are BORN partway along # instead of all of them starting together at the inner band. FREE2 = {**FREE, "shapes": SHAPES2, "a_min": (38.0, 52.0), "drift": 0.78, "max_free_run": 34.0, "max_hole": 26.0, "max_merge_run": 12.0} # A free-strand FLOOR. On the plate the one rule does not apply -- every strut is a bar lying # on the bed from layer one -- so beta is limited by nothing but taste, and these spiral hard. # The births are what make it possible at all: a disc's circumference grows eightfold from hub # to rim, so a fixed strand count is either fused at the middle or 40 mm apart at the edge. FLOOR_FREE = dict(style="free", N=92, born=66, beta=64.0, drift=0.68, w=0.9, band_lo=7.0, band_hi=3.5, born_span=(0.06, 1.0), seed=3, max_hole=17.0, max_free_run=22.0, max_merge_run=14.0, max_slot_run=5.0) SPIRAL = dict(style="spiral", N=6, beta=70.0, splits=[16, 33], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.40, q=5, psi=0.16)) BOWLS = { # ---- Round three: three generators, not three parameter sets. Same silhouette as # thicket in all three so the only thing being compared is how the wall is made. # # gyre: the pattern belongs to the WALL. Every strand is an integral curve of one shared # steering field -- six harmonics for the slow swirl and six "rocks", derivative-of-Gaussian # bumps that strands part around and close up behind. Same-family strands solve the same # ODE so they can never cross, which kills the shearing failure for free and leaves the # solver nothing to choose but the start angles; those are found by relaxation, not search. "gyre": {**FREE2, "style": "flow", "N": 88, "seed": 3, "lean": 0.62, "steer": 0.38, "modes": 6, "rocks": 6, "max_free_run": 34.0, "floor_mesh": FLOOR_FREE}, # coral: how many strands there are is an OUTPUT. March up the wall and hold one number -- # the local hoop gap -- by forking a branch into any gap that opens and ending one in any # pair that closes; a branch that touches nothing for 15 mm dies back. 626 strands from a # start of 48, of which 578 were born on a parent and 501 ended in a tip. "coral": {**FREE2, "style": "vein", "N": 48, "seed": 11, "pitch": (4.5, 10.0), "check": 3.0, "cap_margin": 0.90, "max_merge_run": 16.0, "max_free_run": 30.0, "max_hole": 26.0, "max_slot_run": 8.0, "floor_mesh": SPIRAL, # 545 strands of 0.8 mm wire on a 0.34 mm grid is 2.4 cells across a strut, and # that is the regime where a one-cell gap snaps shut into a bow-tie -- it did. "cell": 0.28, "segs": 2400}, # shale: not strands at all. Nodes and short straight struts -- a leaded membrane whose # cells are triangles, quads and pentagons of wildly different size. The node pitch is not # a free parameter: a strut may only lean tan(beta) sideways per mm of rise, so the courses # are fine at the foot where the wall leans and coarse at the rim where it stands up. "shale": {**FREE2, "style": "mosaic", "w": 0.95, "seed": 3, "row": 17.0, "pitch": 14.0, "floor_mesh": SPIRAL}, # tumble: thicket's fabric on a tall narrow silhouette -- 174 mm across and 109 tall against # thicket's 206 x 80. The wall is 116 mm of arc instead of 92, so a strand gets two or three # periods of its own equation to develop instead of one and a bit, which is most of why this # reads as woven rather than scribbled. "tumble": {**FREE2, "N": 66, "seed": 11, "floor_r": 42.0, "r_rim": 86.0, "s0": 54.0, "s1": 82.0, "floor_mesh": {**SPIRAL, "splits": [14, 29], "band_lo": 6.0}}, # ember: the angle rule RUN BACKWARDS -- 52 deg at the foot easing to 36 at the rim, where # every other bowl here tightens with height. It only works on a steep wall: at sigma 48 and # a_min 52 the budget is exactly zero (the arccos clamps), every strand runs dead vertical # and nothing ever meets anything, which is what a 49 mm unsupported run looks like. At # sigma 66 -> 86 the budget instead GROWS with height, 30 deg at the foot to 53 at the rim, # so the fabric starts orderly and near-vertical and comes apart as it rises. "ember": {**FREE2, "N": 72, "seed": 17, "a_min": (52.0, 36.0), "max_hole": 30.0, "floor_r": 70.0, "r_rim": 96.0, "s0": 66.0, "s1": 86.0, "floor_mesh": {**SPIRAL, "splits": [18, 38]}}, # shoal: a platter -- 202 across, 38 tall, and the wall is a 45 mm collar rather than the # body. A shallow wall cannot carry a steep mesh (sin alpha = cos beta sin sigma caps beta # at 35 deg here however the budget is spent), so the interest moves to the floor, which is # 72 mm of free strands and has no angle rule to obey at all. N is high because at low beta # two strands converge slowly: fewer of them and the crossings are too far apart to brace. "shoal": {**FREE2, "N": 120, "seed": 23, "a_min": (36.0, 46.0), "drift": 0.86, "floor_r": 72.0, "r_rim": 100.0, "s0": 44.0, "s1": 62.0, "max_free_run": 30.0, "max_hole": 20.0, "floor_mesh": FLOOR_FREE}, # kindling: thicket's exact silhouette, drawn only from the three new equations, so the # comparison is clean. kink is the one that changes the character: long straight runs and # two or three hard elbows, a strand that reads as a decision rather than a wave. Twice in # the list because it is the point of the bowl. "kindling": {**FREE2, "N": 76, "seed": 11, "shapes": ("line", "kink", "kink", "gust", "damp"), "floor_mesh": FLOOR_FREE}, # scrawl / thicket: the angle rule eased to 52 deg off vertical at the foot and 38 at the # rim. That is looser than the 45 -> 30 asked for, and it is the whole reason these look # different: the budget it frees is what a strand wanders with, and at 45 -> 30 there is # only enough left over to wobble by a third of a cell. 36 deg is this project's own # long-proven floor (0.28 mm step per layer, under one line width) and every bowl in the # original gallery is built on it, so 38 is not a gamble -- it is just less conservative # than the last three. weft is the same solver at exactly 45 -> 30 for comparison. "scrawl": {**FREE, "N": 88, "a_min": (38.0, 52.0), "drift": 0.72, "seed": 3, "max_free_run": 34.0, "max_hole": 24.0, "max_merge_run": 12.0, "floor_mesh": dict(style="flower", R=24.0, w=0.9, band_lo=6.5, band_hi=3.5, stroke=dict(A=0.32, q=6, psi=0.10))}, "thicket": {**FREE, "N": 76, "a_min": (38.0, 52.0), "drift": 0.78, "seed": 11, "max_free_run": 34.0, "max_hole": 26.0, "max_merge_run": 12.0, "floor_mesh": dict(style="spiral", N=6, beta=70.0, splits=[16, 33], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.40, q=5, psi=0.16))}, "weft": {**FREE, "N": 88, "a_min": (44.0, 60.0), "drift": 0.80, "seed": 7, "max_free_run": 42.0, "max_hole": 24.0, "max_merge_run": 12.0, "floor_mesh": dict(style="flower", R=28.0, w=0.9, band_lo=6.5, band_hi=3.5, circles=(20.0, 36.0), stroke=dict(A=0.34, q=12, psi=0.09))}, # Three octaves of resting offset: whole sectors go sparse or crowded, bundles of ~4 strands # wander together inside them, and neighbours still differ. A3 is a wave far slower than # the wall is tall, so it is really a per-strand LEAN -- each strand shears its own way and # the cells stop agreeing on a shape. That is where the rhythm goes. "bramble": {**CHAOS, "N": 76, "jitter": dict(A=0.60, lam=70.0, A2=0.12, lam2=18.0, A3=2.2, lam3=240.0, off=4.5, bundle=4, spread=0.55, seed=5), "stroke": dict(A=0.26, q=7, psi=0.08), "floor_mesh": dict(style="flower", R=24.0, w=0.9, band_lo=6.5, band_hi=3.5, stroke=dict(A=0.32, q=6, psi=0.10))}, # squall needed the widest wire of the three: at spread 0.60 two strands of opposite # families ran nearly parallel somewhere and the field pinched into a bow-tie. "squall": {**CHAOS, "N": 88, "w": 0.85, # and it needs the least jitter cost: at 88 strands the two families meet at # only ~20 deg, and the sliver of void inside so acute a crossing is what # pinches shut. Spending less on the jitter leaves more drift, which opens # the crossing angle back up. "jitter": dict(A=0.60, lam=80.0, A2=0.14, lam2=16.0, A3=1.6, lam3=240.0, off=2.8, bundle=5, spread=0.45, seed=23), "stroke": dict(A=0.26, q=7, psi=0.08), # the floor shrank to r 54, so rings at 30/50 now sit on its band edge -- a # contour lying a cell from the edge is exactly the bow-tie case. Move them in. "floor_mesh": dict(style="flower", R=21.0, w=0.9, band_lo=6.5, band_hi=3.5, circles=(20.0, 36.0), stroke=dict(A=0.34, q=12, psi=0.09))}, "nest": {**CHAOS, "N": 70, "jitter": dict(A=0.55, lam=80.0, A2=0.10, lam2=21.0, A3=2.0, lam3=240.0, off=6.5, bundle=4, spread=0.50, seed=61), "stroke": dict(A=0.22, q=5, psi=0.07), "floor_mesh": dict(style="spiral", N=6, beta=70.0, splits=[16, 33], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.40, q=5, psi=0.16))}, # --- cypress / wheat / iris: NO two strands bend the same way. # A wave every strand shares is still a repeat -- rotate the bowl by one strand and it is # the same object, which is exactly why wobble/curl/moss still read as a regular grid. So # the shared wave is dropped and its whole budget goes into a PER-STRAND curve: strand m # draws its own amplitude, wavelength, phase and resting offset from a hash of m, and the # two families are hashed independently. The net is untouched -- every strand is still one # line from band to band and the drift G still carries it across every strand of the other # family -- but the cells are now all different sizes and no line repeats. # The resting offset is the strongest lever and the cheapest: it does not lean the strand # at all, so it costs no beta budget, and it is what makes the pitch irregular. It is drawn # as smooth noise over the strand index rather than independently per strand, so the fabric # wanders in BUNDLES of ~6-9 strands (which looks like brushwork) instead of neighbours # jumping at each other and closing a hole. "cypress": {**VG, "w": 0.9, "N": 120, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "jitter": dict(A=0.75, lam=34.0, A2=0.18, lam2=16.0, off=2.4, bundle=4, spread=0.40, seed=3), "stroke": dict(A=0.20, q=6, psi=0.05), "floor_mesh": dict(style="flower", R=22.0, w=0.9, band_lo=6.5, band_hi=3.5, stroke=dict(A=0.30, q=6, psi=0.10))}, "wheat": {**VG, "w": 0.85, "N": 138, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1800, "cell": 0.40, "glb_tris": 160_000, "jitter": dict(A=0.85, lam=30.0, A2=0.16, lam2=13.0, off=2.0, bundle=7, spread=0.45, seed=17), "stroke": dict(A=0.24, q=9, psi=0.09), "floor_mesh": dict(style="flower", R=26.0, w=0.9, band_lo=6.5, band_hi=3.5, circles=(30.0, 50.0), stroke=dict(A=0.32, q=12, psi=0.09))}, "iris": {**VG, "w": 0.9, "N": 96, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "jitter": dict(A=0.65, lam=42.0, A2=0.15, lam2=19.0, off=2.8, bundle=5, spread=0.50, seed=41), "stroke": dict(A=0.30, q=5, psi=0.12), "floor_mesh": dict(style="spiral", N=6, beta=70.0, splits=[16, 33], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.40, q=5, psi=0.16))}, # --- wobble: the same loose diamond grid, but the lines are wavy. # The drift G still makes the grid; a q=0 wave rides on top, so both families snake in # antiphase and every line bows. Wavelength is the whole trick and is what I had wrong # before: psi = 0.005..0.03 is a 200-1200 mm wave, longer than the wall, so nothing shows. # lam 24..44 mm puts 2-4 waves up the wall. Amplitude A = f tan(beta) lam / 2pi is # INDEPENDENT of the strand count, so N is free to rise and keep cells short without # costing any waviness - and A ~ 0.5-0.8 mm against a 0.9 mm wire is a full wire width of # deviation, which is what reads as wavy. q=0 means the wave varies with height only, so # crossings stay at fixed heights and splits would still be legal here. "wobble": {**VG, "w": 0.9, "N": 120, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=0.88, q=0, psi=0.2027), # lam 31 mm, A 0.75 mm "stroke": dict(A=0.20, q=6, psi=0.05), "floor_mesh": dict(style="flower", R=22.0, w=0.9, band_lo=6.5, band_hi=3.5, stroke=dict(A=0.30, q=6, psi=0.10))}, "curl": {**VG, "w": 0.9, "N": 104, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=0.82, q=0, psi=0.1428), # lam 44 mm, lazier and wider "sway": dict(D=0.8, p=3, om=0.02), # plus a slow 3-lobe drift "stroke": dict(A=0.26, q=10, psi=0.07), "floor_mesh": dict(style="flower", R=26.0, w=0.9, band_lo=6.5, band_hi=3.5, circles=(30.0, 50.0), stroke=dict(A=0.32, q=12, psi=0.09))}, "moss": {**VG, "w": 0.9, "N": 140, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=0.71, q=0, psi=0.2618), # lam 24 mm, tightest wobble "stroke": dict(A=0.34, q=9, psi=0.14), # heaviest thick/thin strokes "mullion": dict(n=20, w=1.0), "floor_mesh": dict(style="spiral", N=6, beta=70.0, splits=[16, 33], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.40, q=5, psi=0.16))}, # --- weave: wavy lines, the whole budget spent on the wave. No splits (G barely drifts, # so there is no crossing ladder to snap a split onto) and none needed - r spans only 1.5x. "weave": {**VG, "w": 0.9, "N": 120, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(mode="weave", lam=36.0, B=2.45), "stroke": dict(A=0.20, q=6, psi=0.05), "floor_mesh": dict(style="flower", R=22.0, w=0.9, band_lo=6.5, band_hi=3.5, stroke=dict(A=0.30, q=6, psi=0.10))}, "braid": {**VG, "w": 0.9, "N": 96, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(mode="weave", lam=48.0, B=2.15, drift=0.15), "mullion": dict(n=24, w=1.1), "stroke": dict(A=0.26, q=12, psi=0.06), "floor_mesh": dict(style="spiral", N=6, beta=70.0, splits=[16, 33], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.40, q=5, psi=0.16))}, "vine": {**VG, "w": 0.9, "N": 132, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(mode="weave", lam=28.0, B=1.93), "stroke": dict(A=0.34, q=9, psi=0.14), "floor_mesh": dict(style="flower", R=26.0, w=0.9, band_lo=6.5, band_hi=3.5, circles=(30.0, 50.0), stroke=dict(A=0.32, q=12, psi=0.09))}, # --- gothic: pointed cells, vertical mullions, nested arcades. # A straight cone (s0 = s1) so the wall reads architecturally, and splits ARE allowed here: # the ogive lean varies with height only, so crossings stay at fixed heights and a new # strand is still born exactly on one. Doubling the strand count partway up is what makes # the nested-arcade tracery: tall lancets below, a finer arcade springing above them. "gothic": {**VG, "w": 0.9, "N": 72, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "splits": [84.0], "ogive": dict(eps=0.10), "mullion": dict(n=24, w=1.1), "stroke": dict(A=0.22, q=12, psi=0.05), "floor_mesh": dict(style="flower", R=22.0, w=0.9, band_lo=6.5, band_hi=3.5, stroke=dict(A=0.30, q=6, psi=0.10))}, "rose": {**VG, "w": 0.9, "N": 60, "floor_r": 66.0, "s0": 67.5, "s1": 67.5, "band_hi": 5.0, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "splits": [80.0], "ogive": dict(eps=0.07), "mullion": dict(n=20, w=1.2), "stroke": dict(A=0.28, q=10, psi=0.08), "floor_mesh": dict(style="flower", R=26.0, w=0.9, band_lo=6.5, band_hi=3.5, circles=(30.0, 50.0), stroke=dict(A=0.32, q=12, psi=0.09))}, # --- sacred geometry, which is only possible on the floor. A circle drawn on the WALL # would run horizontal at its top and bottom, i.e. 90 deg off vertical, so a flower there # is unprintable by the rule. On the floor there is no rule to break, so the mandala goes # there and the wall echoes it in symmetry only: lobe count 12 and 6 against the flower's # six-fold lattice. Floors are larger here (70 mm) so the figure is actually visible. "flower": {**VG, "w": 0.9, "N": 96, "floor_r": 70.0, "band_hi": 4.5, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=1.1, q=12, psi=0.006), "sway": dict(D=1.0, p=6, om=0.015), "stroke": dict(A=0.20, q=6, psi=0.12), "floor_mesh": dict(style="flower", R=26.0, w=0.9, band_lo=6.5, band_hi=3.5, stroke=dict(A=0.35, q=6, psi=0.10))}, "seed": {**VG, "w": 0.9, "N": 84, "floor_r": 70.0, "band_hi": 4.5, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=1.4, q=6, psi=0.005), "sway": dict(D=1.6, p=3, om=0.009), "stroke": dict(A=0.25, q=3, psi=0.07), "floor_mesh": dict(style="flower", R=19.5, w=0.9, band_lo=6.5, band_hi=3.5, circles=(26.0, 45.0, 62.0), stroke=dict(A=0.30, q=12, psi=0.08))}, # --- thin generation: w 0.9 mm (two extrusion lines), radial depth still 1.6 mm. # Narrowing w and KEEPING the depth is what buys openness cheaply: a strut's bending # stiffness goes as depth^3, and the depth is the radial direction, which is also the one # fruit loads. Thinning both instead would cost the same mass at a quarter the stiffness. # The binding limit is now the free strut length between crossings: pattern spend lowers # beta, which stretches cells, and a 0.9 mm strut over 34 mm buckles near 1.7 N. Cells are # held at or under ~26 mm here. Openness and weirdness pull against each other through # exactly that term, so the three sit at different points on it. "sinew": {**VG, "w": 0.9, "N": 86, "band_hi": 4.5, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=1.6, q=2, psi=0.004), "sway": dict(D=1.6, p=2, om=0.008), "stroke": dict(A=0.22, q=2, psi=0.04), "floor_mesh": dict(style="spiral", N=5, beta=76.0, splits=[16, 32], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.45, q=3, psi=0.22))}, "lace": {**VG, "w": 0.9, "N": 100, "band_hi": 4.5, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=3.0, q=4, psi=0.005), "sway": dict(D=1.2, p=3, om=0.012), "stroke": dict(A=0.16, q=3, psi=0.09), "floor_mesh": dict(style="spiral", N=6, beta=70.0, splits=[16, 33], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.40, q=5, psi=0.16))}, "frost": {**VG, "w": 0.9, "N": 112, "band_hi": 4.5, "band_lo": 3.5, "segs": 1700, "cell": 0.42, "glb_tris": 150_000, "wave": dict(B=2.2, q=8, psi=0.008), "sway": dict(D=0.8, p=5, om=0.018), "stroke": dict(A=0.22, q=6, psi=0.16), "floor_mesh": dict(style="spiral", N=10, beta=52.0, splits=[18, 36], w=0.9, band_lo=7.0, band_hi=3.5, stroke=dict(A=0.35, q=9, psi=0.30))}, # starry: tightest net, sway in 3 lobes. eddy: most of the budget spent on sway, so the # fabric leans and returns across the height - the flowing one, at the cost of taller cells. # ripple: fine high-frequency lobing and the heaviest brush-weight contrast. "starry": {**VG, "wave": dict(B=1.1, q=4, psi=0.009), "sway": dict(D=1.8, p=3, om=0.033), "stroke": dict(A=0.30, q=3, psi=0.10), "floor_mesh": dict(style="spiral", N=6, beta=68.0, splits=[16, 33], w=1.3, band_lo=7.0, band_hi=3.0, stroke=dict(A=0.26, q=5, psi=0.16))}, "eddy": {**VG, "wave": dict(B=0.8, q=2, psi=0.004), "sway": dict(D=3.0, p=2, om=0.042), "stroke": dict(A=0.34, q=2, psi=0.05), "floor_mesh": dict(style="spiral", N=5, beta=74.0, splits=[16, 32], w=1.3, band_lo=7.0, band_hi=3.0, stroke=dict(A=0.24, q=3, psi=0.22))}, "ripple": {**VG, "wave": dict(B=1.0, q=9, psi=0.020), "sway": dict(D=0.9, p=6, om=0.070), "stroke": dict(A=0.24, q=7, psi=0.20), "floor_mesh": dict(style="spiral", N=12, beta=48.0, splits=[18, 36], w=1.3, band_lo=7.0, band_hi=3.0, stroke=dict(A=0.22, q=9, psi=0.30))}, "cyl": {**BASKET, "floor_r": 90.0, "wall": [(80.0, 90.0)], "N": 72}, "flare": {**BASKET, "floor_r": 75.0, "wall": [(18.0, 90.0), (62.0, 68.0)], "N": 62}, "belly": {**BASKET, "floor_r": 78.0, "wall": [(26.0, 66.0), (28.0, 90.0), (26.0, 114.0)], "N": 64}, "solo": {**SINGLE, "N": 32, "splits": [52]}, "steep": {**SINGLE, "N": 36, "splits": [52], "beta": 28}, "deep": dict(kind="single", floor=20.0, fillet=8.0, s0=48.0, s1=70.0, r_rim=85.0, N=28, splits=[48]), "duo": {**DOUBLE, "N": 32, "splits": [52], "inner": dict(phase=0.5)}, "moire": {**DOUBLE, "N": 32, "splits": [52], "inner": dict(N=36, splits=[62], w=1.4)}, } if __name__ == "__main__": bpy.ops.wm.read_factory_settings(use_empty=True) os.makedirs(OUT, exist_ok=True) only = [n for n in os.environ.get("ONLY", "").split(",") if n] names = only or list(BOWLS) entries = [] for n in names: e = build(n, BOWLS[n]) if e: entries.append(e) if entries: mp = os.path.join(OUT, "manifest.json") old = {e["name"]: e for e in json.load(open(mp))} if os.path.exists(mp) else {} for e in entries: old[e["name"]] = {**old.get(e["name"], {}), **e} json.dump([old[n] for n in list(BOWLS) if n in old] + [e for n, e in old.items() if n not in BOWLS], open(mp, "w"), indent=1) print(f"manifest: {len(old)} bowls")