import bpy
import bmesh
import math
import random
from mathutils import Vector, Matrix, Euler

random.seed(20240925)

# ----------------------------------------------------------------------
# Utility helpers
# ----------------------------------------------------------------------

def make_material(name, base_color, roughness=0.7, subsurface=0.0, sss_color=None, metallic=0.0):
    """Create a minimal, defensive Principled BSDF material."""
    mat = bpy.data.materials.get(name)
    if mat is None:
        mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = None
    for n in nt.nodes:
        if n.type == 'BSDF_PRINCIPLED':
            bsdf = n
            break
    if bsdf is None:
        bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
    # Base color
    try:
        bsdf.inputs['Base Color'].default_value = (base_color[0], base_color[1], base_color[2], 1.0)
    except Exception:
        pass
    # Roughness
    try:
        bsdf.inputs['Roughness'].default_value = float(roughness)
    except Exception:
        pass
    # Metallic
    try:
        bsdf.inputs['Metallic'].default_value = float(metallic)
    except Exception:
        pass
    # Subsurface (Blender 4.x renamed 'Subsurface' -> 'Subsurface Weight')
    try:
        if subsurface > 0.0:
            if 'Subsurface Weight' in bsdf.inputs:
                bsdf.inputs['Subsurface Weight'].default_value = float(subsurface)
            elif 'Subsurface' in bsdf.inputs:
                bsdf.inputs['Subsurface'].default_value = float(subsurface)
            col = sss_color if sss_color else base_color
            if 'Subsurface Color' in bsdf.inputs:
                bsdf.inputs['Subsurface Color'].default_value = (col[0], col[1], col[2], 1.0)
            if 'Subsurface Radius' in bsdf.inputs:
                bsdf.inputs['Subsurface Radius'].default_value = (0.02, 0.02, 0.02)
    except Exception:
        pass
    return mat


def new_mesh_object(name, verts, faces):
    """Create a mesh object from vertex/face lists and link to the scene."""
    mesh = bpy.data.meshes.new(name + "_mesh")
    mesh.from_pydata(verts, [], faces)
    mesh.update()
    mesh.validate()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


def assign_material(obj, mat):
    if obj is None or mat is None:
        return
    if obj.data.materials:
        obj.data.materials[0] = mat
    else:
        obj.data.materials.append(mat)


def shade_smooth(obj):
    if obj is None or obj.data is None:
        return
    for p in obj.data.polygons:
        p.use_smooth = True


def bmesh_to_object(bm, name):
    mesh = bpy.data.meshes.new(name + "_mesh")
    bm.to_mesh(mesh)
    bm.free()
    mesh.update()
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    return obj


# ----------------------------------------------------------------------
# Materials
# ----------------------------------------------------------------------

mat_stalk   = make_material("Maize_Stalk",   (0.35, 0.52, 0.13), roughness=0.55, subsurface=0.15)
mat_leaf    = make_material("Maize_Leaf",    (0.24, 0.45, 0.10), roughness=0.5,  subsurface=0.2)
mat_leaf_dry= make_material("Maize_LeafDry", (0.55, 0.52, 0.22), roughness=0.7)
mat_husk    = make_material("Maize_Husk",    (0.62, 0.70, 0.34), roughness=0.6,  subsurface=0.15)
mat_cob     = make_material("Maize_Cob",     (0.88, 0.82, 0.55), roughness=0.75)
mat_kernel  = make_material("Maize_Kernel",  (0.95, 0.80, 0.15), roughness=0.35, subsurface=0.35,
                            sss_color=(0.95, 0.75, 0.2))
mat_silk    = make_material("Maize_Silk",    (0.80, 0.60, 0.35), roughness=0.85)
mat_tassel  = make_material("Maize_Tassel",  (0.70, 0.68, 0.40), roughness=0.7)
mat_pollen  = make_material("Maize_Anther",  (0.85, 0.75, 0.30), roughness=0.6)
mat_root    = make_material("Maize_Root",    (0.72, 0.62, 0.42), roughness=0.9)


# ----------------------------------------------------------------------
# Global plant parameters
# ----------------------------------------------------------------------

PLANT_HEIGHT = 2.4               # meters, tip of stalk (before tassel)
N_NODES = 12                     # number of stem internodes
STALK_BASE_R = 0.045
STALK_TIP_R  = 0.015

# node heights (0 at ground)
node_heights = [PLANT_HEIGHT * (i / N_NODES) ** 1.02 for i in range(N_NODES + 1)]

# radius at each node
def stalk_radius_at(t):
    # t in 0..1, slight bulge near base
    r = STALK_BASE_R + (STALK_TIP_R - STALK_BASE_R) * t
    r *= (1.0 + 0.12 * math.sin(t * math.pi))  # gentle taper bulge
    return r

all_objects = []


# ----------------------------------------------------------------------
# 1. Main stalk (stem with visible nodes)
# ----------------------------------------------------------------------

def build_stalk():
    bm = bmesh.new()
    segments = 16
    rings = []
    n_samples = N_NODES * 4
    for s in range(n_samples + 1):
        t = s / n_samples
        z = t * PLANT_HEIGHT
        base_r = stalk_radius_at(t)
        # node bulges: find nearest node fraction
        node_bulge = 0.0
        for i in range(N_NODES + 1):
            nt = i / N_NODES
            d = abs(t - nt)
            node_bulge = max(node_bulge, math.exp(-(d * n_samples) ** 2 * 0.9) * 0.22)
        r = base_r * (1.0 + node_bulge)
        # slight organic lean
        lean_x = 0.03 * math.sin(t * math.pi * 0.6)
        ring = []
        for a in range(segments):
            ang = 2 * math.pi * a / segments
            x = lean_x + r * math.cos(ang)
            y = r * math.sin(ang)
            ring.append(bm.verts.new((x, y, z)))
        rings.append(ring)
    # connect rings
    for i in range(len(rings) - 1):
        r0 = rings[i]
        r1 = rings[i + 1]
        for a in range(segments):
            b = (a + 1) % segments
            bm.faces.new((r0[a], r0[b], r1[b], r1[a]))
    # cap bottom
    bm.faces.new(list(reversed(rings[0])))
    bm.faces.new(rings[-1])
    bm.normal_update()
    obj = bmesh_to_object(bm, "Stalk")
    assign_material(obj, mat_stalk)
    shade_smooth(obj)
    all_objects.append(obj)
    return obj

build_stalk()


# ----------------------------------------------------------------------
# 2. Leaves (long lanceolate blades with midrib + sheath wrapping stem)
# ----------------------------------------------------------------------

def build_leaf(node_index, azimuth, dry=False):
    """Build one leaf sprouting from a node."""
    t0 = node_index / N_NODES
    base_z = t0 * PLANT_HEIGHT
    stem_r = stalk_radius_at(t0)

    # leaf size scales: larger in mid canopy
    canopy = math.sin(min(1.0, t0 / 0.85) * math.pi)
    length = 0.7 + 1.1 * canopy
    width = 0.045 + 0.05 * canopy

    bm = bmesh.new()
    n_len = 26
    n_wid = 5   # half-width samples each side (+center)

    # arch: leaf rises then droops
    droop = 0.55 + 0.3 * random.random()

    # direction outward in azimuth
    dx = math.cos(azimuth)
    dy = math.sin(azimuth)

    prev_row = None
    for i in range(n_len + 1):
        s = i / n_len
        # path along leaf: starts at stem, arcs up then down
        horiz = length * s
        # vertical arc
        z_off = length * (0.9 * math.sin(s * math.pi * 0.9)) * (1.0 - droop * s * 0.7)
        # add gentle downward curl near tip
        z_off -= length * droop * (s ** 2) * 0.5

        cx = dx * (stem_r * 0.8 + horiz)
        cy = dy * (stem_r * 0.8 + horiz)
        cz = base_z + z_off

        # width profile: narrow at base, widen, taper to point
        wprof = math.sin(min(1.0, s * 1.15) * math.pi) ** 0.7
        w = width * wprof
        if s > 0.9:
            w *= max(0.0, (1.0 - (s - 0.9) / 0.1))

        # perpendicular horizontal direction
        px = -dy
        py = dx

        # slight wavy edge / channel (midrib lower, edges up a bit)
        row = []
        for j in range(-n_wid, n_wid + 1):
            fw = j / n_wid
            offset = fw * w
            # V-channel cross-section: center dips slightly
            channel = -(1.0 - fw * fw) * w * 0.25
            # tiny wave along length for realism
            wave = 0.01 * w * math.sin(s * 12.0 + fw * 3.0)
            vx = cx + px * offset
            vy = cy + py * offset
            vz = cz + channel + wave
            row.append(bm.verts.new((vx, vy, vz)))
        if prev_row is not None:
            for k in range(len(row) - 1):
                bm.faces.new((prev_row[k], prev_row[k + 1], row[k + 1], row[k]))
        prev_row = row

    bm.normal_update()
    obj = bmesh_to_object(bm, "Leaf_%02d" % node_index)
    assign_material(obj, mat_leaf_dry if dry else mat_leaf)
    shade_smooth(obj)
    # thin solidify for double-sided thickness
    solid = obj.modifiers.new("Solid", 'SOLIDIFY')
    solid.thickness = 0.0025
    all_objects.append(obj)

    # sheath: short tube hugging stem below node
    build_sheath(node_index, azimuth, dry)
    return obj


def build_sheath(node_index, azimuth, dry=False):
    t0 = node_index / N_NODES
    top_z = t0 * PLANT_HEIGHT
    bot_index = max(0, node_index - 1)
    bot_z = top_z - (PLANT_HEIGHT / N_NODES) * 0.9
    r_top = stalk_radius_at(t0) * 1.18
    r_bot = stalk_radius_at(bot_index / N_NODES) * 1.22

    bm = bmesh.new()
    seg = 14
    rings = []
    steps = 6
    for st in range(steps + 1):
        f = st / steps
        z = bot_z + (top_z - bot_z) * f
        r = r_bot + (r_top - r_bot) * f
        # sheath opens toward the leaf azimuth at top
        ring = []
        for a in range(seg):
            ang = 2 * math.pi * a / seg
            flare = 1.0 + f * 0.15 * math.cos(ang - azimuth)
            x = r * math.cos(ang) * flare
            y = r * math.sin(ang) * flare
            ring.append(bm.verts.new((x, y, z)))
        rings.append(ring)
    for i in range(len(rings) - 1):
        for a in range(seg):
            b = (a + 1) % seg
            bm.faces.new((rings[i][a], rings[i][b], rings[i + 1][b], rings[i + 1][a]))
    bm.normal_update()
    obj = bmesh_to_object(bm, "Sheath_%02d" % node_index)
    assign_material(obj, mat_leaf_dry if dry else mat_leaf)
    shade_smooth(obj)
    all_objects.append(obj)
    return obj


# distribute leaves alternately (phyllotaxy ~180 deg with slight rotation)
leaf_azimuth = random.uniform(0, math.tau)
for ni in range(1, N_NODES):
    t0 = ni / N_NODES
    dry = t0 < 0.18   # lowest couple of leaves senescing
    build_leaf(ni, leaf_azimuth, dry=dry)
    leaf_azimuth += math.pi + random.uniform(-0.15, 0.15)


# ----------------------------------------------------------------------
# 3. Ear of corn (cob + kernels + husk + silk) on a mid-node
# ----------------------------------------------------------------------

def build_ear(node_index, azimuth):
    t0 = node_index / N_NODES
    base_z = t0 * PLANT_HEIGHT
    stem_r = stalk_radius_at(t0)

    dx = math.cos(azimuth)
    dy = math.sin(azimuth)

    # ear points outward and slightly up then droops
    ear_len = 0.22
    ear_r = 0.038
    # attachment point on stalk
    ax = dx * stem_r
    ay = dy * stem_r
    # ear axis: outward + upward-ish then tip lower
    tip_x = ax + dx * ear_len * 1.3
    tip_y = ay + dy * ear_len * 1.3
    base_ear_z = base_z + 0.02
    tip_z = base_ear_z + ear_len * 0.35

    # peduncle (short stalk of the ear)
    build_ear_stalk(ax, ay, base_ear_z, dx, dy)

    # --- Cob core ---
    bm = bmesh.new()
    seg = 20
    steps = 24
    axis_pts = []
    for st in range(steps + 1):
        f = st / steps
        cx = ax + dx * ear_len * 1.3 * f
        cy = ay + dy * ear_len * 1.3 * f
        cz = base_ear_z + (tip_z - base_ear_z) * f + 0.03 * math.sin(f * math.pi) * 0.0
        axis_pts.append(Vector((cx, cy, cz)))
    rings = []
    radii = []
    for st in range(steps + 1):
        f = st / steps
        # radius profile: rounded ends
        rr = ear_r * math.sin(min(1.0, (f * 1.05 + 0.02)) * math.pi) ** 0.35
        rr = max(rr, 0.004)
        radii.append(rr)
        c = axis_pts[st]
        ring = []
        # perpendicular frame
        px = -dy
        py = dx
        for a in range(seg):
            ang = 2 * math.pi * a / seg
            ox = px * math.cos(ang) * rr
            oy = py * math.cos(ang) * rr
            oz = math.sin(ang) * rr
            ring.append(bm.verts.new((c.x + ox, c.y + oy, c.z + oz)))
        rings.append(ring)
    for i in range(len(rings) - 1):
        for a in range(seg):
            b = (a + 1) % seg
            bm.faces.new((rings[i][a], rings[i][b], rings[i + 1][b], rings[i + 1][a]))
    bm.faces.new(list(reversed(rings[0])))
    bm.faces.new(rings[-1])
    bm.normal_update()
    cob = bmesh_to_object(bm, "Cob")
    assign_material(cob, mat_cob)
    shade_smooth(cob)
    all_objects.append(cob)

    # --- Kernels: little spheroids in rows along the cob ---
    kbm = bmesh.new()
    n_rows = seg
    n_along = 18
    px = -dy
    py = dx
    for st in range(2, steps - 1):
        f = st / steps
        c = axis_pts[st]
        rr = radii[st] + 0.006
        if rr < 0.01:
            continue
        for a in range(n_rows):
            ang = 2 * math.pi * a / n_rows + (0.5 * math.pi / n_rows) * (st % 2)
            ox = px * math.cos(ang)
            oy = py * math.cos(ang)
            oz = math.sin(ang)
            kx = c.x + ox * rr
            ky = c.y + oy * rr
            kz = c.z + oz * rr
            # kernel as small icosphere
            ksize = 0.007 * (0.8 + 0.4 * math.sin(f * math.pi))
            res = bmesh.ops.create_icosphere(kbm, subdivisions=1, radius=ksize)
            nx, ny, nz = ox, oy, oz
            # squash kernels slightly along normal & tag position
            for v in res['verts']:
                v.co.x = v.co.x * 1.0
                v.co.y = v.co.y * 1.0
                v.co.z = v.co.z * 0.75
                v.co += Vector((kx, ky, kz))
    kbm.normal_update()
    kernels = bmesh_to_object(kbm, "Kernels")
    assign_material(kernels, mat_kernel)
    shade_smooth(kernels)
    all_objects.append(kernels)

    # --- Husk leaves wrapping the ear ---
    n_husk = 7
    for h in range(n_husk):
        hang = azimuth + (h - n_husk / 2) * 0.32
        build_husk(ax, ay, base_ear_z, hang, dx, dy, ear_len)

    # --- Silk emerging from ear tip ---
    build_silk(tip_x, tip_y, tip_z, dx, dy)

    return cob


def build_ear_stalk(ax, ay, az, dx, dy):
    bm = bmesh.new()
    seg = 10
    steps = 4
    L = 0.05
    rings = []
    for st in range(steps + 1):
        f = st / steps
        cx = ax + dx * L * f * 0.4
        cy = ay + dy * L * f * 0.4
        cz = az - 0.02 + f * 0.0
        r = 0.02 * (1.0 - 0.2 * f)
        ring = []
        for a in range(seg):
            ang = 2 * math.pi * a / seg
            ring.append(bm.verts.new((cx + r * math.cos(ang) * -dy + 0,
                                      cy + r * math.cos(ang) * dx,
                                      cz + r * math.sin(ang))))
        rings.append(ring)
    for i in range(len(rings) - 1):
        for a in range(seg):
            b = (a + 1) % seg
            bm.faces.new((rings[i][a], rings[i][b], rings[i + 1][b], rings[i + 1][a]))
    bm.normal_update()
    obj = bmesh_to_object(bm, "EarStalk")
    assign_material(obj, mat_stalk)
    shade_smooth(obj)
    all_objects.append(obj)


def build_husk(ax, ay, az, azimuth, main_dx, main_dy, ear_len):
    dx = math.cos(azimuth)
    dy = math.sin(azimuth)
    bm = bmesh.new()
    n_len = 16
    n_wid = 3
    length = ear_len * 1.55
    width = 0.03
    px = -main_dy
    py = main_dx
    # husks follow ear axis direction (main direction) but each offset radially
    prev_row = None
    for i in range(n_len + 1):
        s = i / n_len
        cx = ax + main_dx * length * s
        cy = ay + main_dy * length * s
        cz = az + ear_len * 0.35 * s + 0.005
        # taper to a point at tip
        wprof = math.sin(min(1.0, s * 1.05) * math.pi) ** 0.5
        w = width * wprof
        if s > 0.85:
            w *= max(0.0, 1.0 - (s - 0.85) / 0.15)
        # radial wrap direction from ear center
        rr = 0.045 + 0.01 * s
        # base offset around cob
        offx = (px * math.cos(azimuth - math.atan2(main_dy, main_dx)) )
        row = []
        for j in range(-n_wid, n_wid + 1):
            fw = j / n_wid
            # wrap around cob surface
            local_ang = azimuth + fw * (w / max(rr, 0.01))
            wx = math.cos(local_ang)
            wy = math.sin(local_ang)
            # position on a cylinder around the ear axis
            base_perp_x = -main_dy
            base_perp_y = main_dx
            # combine: husk lies at radius rr around axis, angled by local_ang
            radial = rr * (1.0 + 0.15 * s)
            comp = math.cos(local_ang - azimuth)
            ox = (base_perp_x * math.cos(local_ang) )
            vx = cx + (-main_dy) * (radial * math.cos(local_ang)) * 0 + wx * 0
            # simpler: place husk flap tangentially
            tx = -main_dy
            ty = main_dx
            vx = cx + tx * fw * w
            vy = cy + ty * fw * w
            vz = cz + (radial - ear_len * 0.0) * 0 + rr * math.sin(fw * 1.2) * 0.2 + abs(fw) * 0.0
            # lift husk slightly off cob and curl outward at tip
            lift = rr * 0.6 + s * 0.02
            # orient lift outward from stalk using main perpendicular up component
            vz2 = cz + lift * 0.3 - s * s * 0.02
            row.append(bm.verts.new((vx, vy, vz2 + rr * 0.4)))
        if prev_row is not None:
            for k in range(len(row) - 1):
                bm.faces.new((prev_row[k], prev_row[k + 1], row[k + 1], row[k]))
        prev_row = row
    bm.normal_update()
    obj = bmesh_to_object(bm, "Husk")
    assign_material(obj, mat_husk)
    shade_smooth(obj)
    solid = obj.modifiers.new("Solid", 'SOLIDIFY')
    solid.thickness = 0.002
    all_objects.append(obj)


def build_silk(tx, ty, tz, dx, dy):
    bm = bmesh.new()
    n_strands = 40
    for st in range(n_strands):
        ox = random.uniform(-0.02, 0.02)
        oy = random.uniform(-0.02, 0.02)
        sx = tx + ox
        sy = ty + oy
        sz = tz
        segs = 6
        length = random.uniform(0.08, 0.16)
        ddx = dx * 0.5 + random.uniform(-0.4, 0.4)
        ddy = dy * 0.5 + random.uniform(-0.4, 0.4)
        prev = None
        prev2 = None
        r = 0.0008
        for i in range(segs + 1):
            f = i / segs
            px = sx + ddx * length * f
            py = sy + ddy * length * f
            pz = sz + length * 0.4 * f - length * f * f * 0.6  # droop
            v1 = bm.verts.new((px - r, py, pz))
            v2 = bm.verts.new((px + r, py, pz + r))
            if prev is not None:
                bm.faces.new((prev, prev2, v2, v1))
            prev, prev2 = v1, v2
    bm.normal_update()
    obj = bmesh_to_object(bm, "Silk")
    assign_material(obj, mat_silk)
    all_objects.append(obj)


# place the ear on a middle node
ear_node = int(N_NODES * 0.55)
build_ear(ear_node, leaf_azimuth + math.pi * 0.5)


# ----------------------------------------------------------------------
# 4. Tassel (male inflorescence) at the top
# ----------------------------------------------------------------------

def build_tassel():
    top_z = PLANT_HEIGHT
    tip_r = stalk_radius_at(1.0)

    # central spike
    bm = bmesh.new()
    seg = 8
    steps = 10
    spike_len = 0.35
    rings = []
    for st in range(steps + 1):
        f = st / steps
        z = top_z + spike_len * f
        r = tip_r * (1.0 - 0.85 * f) + 0.002
        lean = 0.02 * math.sin(f * math.pi * 0.5)
        ring = []
        for a in range(seg):
            ang = 2 * math.pi * a / seg
            ring.append(bm.verts.new((lean + r * math.cos(ang), r * math.sin(ang), z)))
        rings.append(ring)
    for i in range(len(rings) - 1):
        for a in range(seg):
            b = (a + 1) % seg
            bm.faces.new((rings[i][a], rings[i][b], rings[i + 1][b], rings[i + 1][a]))
    bm.faces.new(rings[-1])
    bm.normal_update()
    spike = bmesh_to_object(bm, "TasselSpike")
    assign_material(spike, mat_tassel)
    shade_smooth(spike)
    all_objects.append(spike)

    # lateral branches
    n_branches = 9
    branch_base_z = top_z + 0.03
    for b in range(n_branches):
        f = b / max(1, n_branches - 1)
        az = random.uniform(0, math.tau)
        bz = branch_base_z + f * spike_len * 0.7
        dx = math.cos(az)
        dy = math.sin(az)
        blen = 0.18 * (1.0 - 0.4 * f) + 0.05
        build_tassel_branch(dx, dy, bz, blen)

    # anthers/spikelets scattered along spike and branches (small dangling forms)
    build_anthers(top_z, spike_len)


def build_tassel_branch(dx, dy, bz, blen):
    bm = bmesh.new()
    seg = 6
    steps = 8
    rings = []
    for st in range(steps + 1):
        f = st / steps
        cx = dx * blen * f * 0.9
        cy = dy * blen * f * 0.9
        cz = bz + blen * (0.6 * f - 0.4 * f * f)  # arch up then over
        r = 0.004 * (1.0 - 0.7 * f) + 0.0008
        px = -dy
        py = dx
        ring = []
        for a in range(seg):
            ang = 2 * math.pi * a / seg
            ox = px * math.cos(ang) * r
            oy = py * math.cos(ang) * r
            oz = math.sin(ang) * r
            ring.append(bm.verts.new((cx + ox, cy + oy, cz + oz)))
        rings.append(ring)
    for i in range(len(rings) - 1):
        for a in range(seg):
            bb = (a + 1) % seg
            bm.faces.new((rings[i][a], rings[i][bb], rings[i + 1][bb], rings[i + 1][a]))
    bm.normal_update()
    obj = bmesh_to_object(bm, "TasselBranch")
    assign_material(obj, mat_tassel)
    shade_smooth(obj)
    all_objects.append(obj)


def build_anthers(top_z, spike_len):
    bm = bmesh.new()
    n = 120
    for i in range(n):
        f = random.random()
        z = top_z + spike_len * f + 0.02
        rad = random.uniform(0.0, 0.14) * (0.4 + f)
        ang = random.uniform(0, math.tau)
        x = rad * math.cos(ang)
        y = rad * math.sin(ang)
        # anthers are tiny elongated capsules dangling down
        res = bmesh.ops.create_icosphere(bm, subdivisions=0, radius=0.004)
        for v in res['verts']:
            v.co.z *= 2.2  # elongate
            v.co += Vector((x, y, z - 0.01))
    bm.normal_update()
    obj = bmesh_to_object(bm, "Anthers")
    assign_material(obj, mat_pollen)
    shade_smooth(obj)
    all_objects.append(obj)


build_tassel()


# ----------------------------------------------------------------------
# 5. Roots (fibrous root system + brace/prop roots at base)
# ----------------------------------------------------------------------

def build_roots():
    # brace roots: arch out from lowest node(s) into the ground
    n_brace = 8
    for i in range(n_brace):
        ang = math.tau * i / n_brace + random.uniform(-0.1, 0.1)
        start_z = random.uniform(0.04, 0.12)
        build_brace_root(ang, start_z)
    # fibrous roots underground
    n_fib = 30
    for i in range(n_fib):
        ang = random.uniform(0, math.tau)
        build_fibrous_root(ang)


def build_brace_root(ang, start_z):
    bm = bmesh.new()
    seg = 6
    steps = 10
    dx = math.cos(ang)
    dy = math.sin(ang)
    r0 = stalk_radius_at(0.0)
    length = random.uniform(0.15, 0.28)
    rings = []
    for st in range(steps + 1):
        f = st / steps
        # arch outward from stem then plunge below ground
        horiz = length * f
        cx = dx * (r0 + horiz)
        cy = dy * (r0 + horiz)
        cz = start_z - (start_z + 0.08) * (f ** 1.5)  # curve down into soil
        r = 0.008 * (1.0 - 0.7 * f) + 0.0015
        px = -dy
        py = dx
        ring = []
        for a in range(seg):
            a2 = 2 * math.pi * a / seg
            ox = px * math.cos(a2) * r
            oy = py * math.cos(a2) * r
            oz = math.sin(a2) * r
            ring.append(bm.verts.new((cx + ox, cy + oy, cz + oz)))
        rings.append(ring)
    for i in range(len(rings) - 1):
        for a in range(seg):
            b = (a + 1) % seg
            bm.faces.new((rings[i][a], rings[i][b], rings[i + 1][b], rings[i + 1][a]))
    bm.normal_update()
    obj = bmesh_to_object(bm, "BraceRoot")
    assign_material(obj, mat_root)
    shade_smooth(obj)
    all_objects.append(obj)


def build_fibrous_root(ang):
    bm = bmesh.new()
    seg = 5
    steps = 8
    dx = math.cos(ang)
    dy = math.sin(ang)
    r0 = stalk_radius_at(0.0) * 0.5
    length = random.uniform(0.12, 0.3)
    spread = random.uniform(0.3, 1.0)
    rings = []
    wander_x = 0.0
    wander_y = 0.0
    for st in range(steps + 1):
        f = st / steps
        wander_x += random.uniform(-0.02, 0.02)
        wander_y += random.uniform(-0.02, 0.02)
        cx = dx * (r0 + length * f * spread) + wander_x
        cy = dy * (r0 + length * f * spread) + wander_y
        cz = -0.01 - (0.15 + 0.2 * random.random()) * f
        r = 0.004 * (1.0 - 0.8 * f) + 0.0008
        px = -dy
        py = dx
        ring = []
        for a in range(seg):
            a2 = 2 * math.pi * a / seg
            ox = px * math.cos(a2) * r
            oy = py * math.cos(a2) * r
            oz = math.sin(a2) * r
            ring.append(bm.verts.new((cx + ox, cy + oy, cz + oz)))
        rings.append(ring)
    for i in range(len(rings) - 1):
        for a in range(seg):
            b = (a + 1) % seg
            bm.faces.new((rings[i][a], rings[i][b], rings[i + 1][b], rings[i + 1][a]))
    bm.normal_update()
    obj = bmesh_to_object(bm, "FibrousRoot")
    assign_material(obj, mat_root)
    shade_smooth(obj)
    all_objects.append(obj)


build_roots()


# ----------------------------------------------------------------------
# 6. Join everything into a single specimen object (optional, defensive)
# ----------------------------------------------------------------------

def join_all(objects, name="Maize_Zea_mays"):
    valid = [o for o in objects if o and o.name in bpy.data.objects]
    if not valid:
        return None
    bpy.ops.object.select_all(action='DESELECT')
    for o in valid:
        try:
            o.select_set(True)
        except Exception:
            pass
    ctx = bpy.context
    view_layer = ctx.view_layer
    view_layer.objects.active = valid[0]
    try:
        bpy.ops.object.join()
    except Exception:
        pass
    result = view_layer.objects.active
    if result:
        result.name = name
    return result

# Apply modifiers is skipped to keep script safe; join base geometry.
final = join_all(all_objects)

# Ensure something is selected/active at the end
if final and final.name in bpy.data.objects:
    bpy.ops.object.select_all(action='DESELECT')
    final.select_set(True)
    bpy.context.view_layer.objects.active = final