Examples and Showcase
GitHub

rope-bridge

A procedural rope footbridge — log posts, through-tenoned sills, twenty planks on two sagging foot ropes, lashed hand ropes, suspenders and staked tie-offs — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

Rendered headless by the showcase piece itself — click to zoom.

witnesses Recomputed: 8768 tris, two materials with 1356 timber and 3348 rope faces, UVs in 0..1 with zero AABB overlap and 18.8 baked texels per island, outer AABB 4.619×1.073×1.480 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, four posts and four stakes grounded, every rope end and sill tenon biting its host, all 20 planks seated 4.0 mm on both foot ropes, 12 lashing turns hooped 2.4 mm, one connected assembly of 60 shells, posts plumb, plank pitch within 0.08 mm, hand ropes 0.87 m over the deck, and every plank top within 2.97 mm of a least-squares parabola with a 0.220 m sag. --vee-deck exits 19 on the parabola fit with its sag still in band; --float-suspenders exits 18 on the contact graph.

category Village

tags mesh export showcase

blender --background --python showcase/rope-bridge/rope_bridge.py --

A rope footbridge — two pairs of log posts, a squared sill through-tenoned across each end, twenty planks laid on two foot ropes that hang between the sills, two hand ropes lashed to the posts, suspenders tying hand rope to foot rope, and the foot ropes run back over the sills to ground stakes. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.

What it composes#

Shipped contentUsed for
skills/mesh-editing-and-bmeshlathed logs, laid-rope sweeps, chamfered boards, UVs in one bmesh
skills/procedural-materials-and-shaderstimber grain along each member (GrainDir), per-member tone (PlankTone), hemp with a fibre bump
skills/bake-high-to-lowCycles tangent-space normal bake, high onto low
skills/engine-export-presetsUnity glTF (export_yup=True)
skills/depsgraph-and-evaluated-dataevaluated triangle counts for the LOD ratios
snippets/decimate_to_budget.pyLOD1 / LOD2 COLLAPSE chain
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)

snippets/convex_hull_collider.py is deliberately not used; see Collider below.

The budget that matters#

A rope bridge can fail invisibly. A deck of planks laid on two straight ramps that meet at midspan has the same ends, the same sag, the same planks and the same bounding box as one that hangs; only the shape is wrong. A uniformly loaded cable hangs as a parabola, so the piece fits a least-squares parabola to the plank top-face centres read off the finished mesh — not to the function the generator used — and asserts every plank lands within 6 mm of it, with the fitted sag within 15 mm of the declared 0.22 m.

--vee-deck is the falsifier built for exactly this. It keeps the sill heights, the sag, the plank count, the pitch, every seat and the envelope, and swaps the parabola for two straight ramps. Its fitted sag is 0.215 m — inside the sag tolerance — and every other budget passes. Only the residual sees it: 32.5 mm against a 6 mm band.

The measured residual on the true deck is 2.97 mm, not zero, and that is the model rather than noise: each plank's underside is set from the foot rope's own reach at its station (below), and the three-strand lay turns under the deck, so the planks ride a few millimetres up and down on the strands.

Budgets#

Declared in the script as named constants, recomputed from the generated mesh. Measured values are from Blender 5.2.1; every one is byte-identical on 4.5.11 and 5.1.2.

BudgetBandMeasured
Base triangles8300–92008768
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2199
Material slotsexactly 2, distinct2
Timber faces≥ 11001356
Rope faces≥ 28003348
UV boundsinside 0..1(0.0016, 0.0016)–(0.9984, 0.9984)
UV AABB overlap≤ 1e-50.000000
Baked texels per UV island≥ 1218.84 (610 islands, 512 px)
Outer AABB4.619 × 1.073 × 1.480 m ± 0.0204.6185 × 1.0733 × 1.4800
Collider triangles≤ 400360
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~300 kB
Hygieneall zeroloose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0
Grounded AABB\zmin\≤ 1e-40.00000
Named supports4 posts + 4 stakes, each zmin ≤ 1e-38 at 0.00000
Hand-rope ends inside their postsdeepest vertex ≥ 0.030 m0.05669
Foot-rope ends inside their stakesdeepest vertex ≥ 0.020 m0.03347
Sill tenons inside their postsdeepest vertex ≥ 0.010 m0.01980
Plank seat on each foot rope20 planks, bite 0.002–0.008 m0.00400 on all 40
Lashing hoop12 turns, bite 0.0005–0.005 m0.00238–0.00246
One connected assembly1 component1 (60 shells)
Plumb (posts and stakes)bottom-to-top slab centroid ≤ 0.004 m0.00000
Deck parabolaevery plank top within 0.006 m; sag 0.22 ± 0.015 m0.00297; 0.22037
Plank pitchevery gap within 0.003 m of the mean0.00008 (mean 0.1689)
Rail height at midspan0.80–1.00 m above the deck0.86521
Right-angle edges00

Real-world size: a 3.6 m span between post centres carrying a 3.2 m walkable deck of 0.80 m planks, hand ropes 0.87 m over the deck at midspan, posts 1.48 m tall — a garden or gorge footbridge module, 4.6 m overall with its stakes.

Construction#

Conventions walked#

Every convention in showcase/README.md, and whether it applies here.

ConventionAppliesHow
Deterministic, budgets declared, assertions recomputeyesseed 31; every value above is read off the mesh
Falsifier fails the budget it targetsyestable below, proven on all three binaries
Hygiene incl. cross-shell coplanaryesexit 15; KD-tree range query, cross-shell
Named supportsyes4 posts + 4 stakes (--float-post)
Joint-fit / a joint bitesyesrope ends and sill tenons, deepest vertex by signed distance (--short-rails)
Diagonal from stationsyessuspenders run from the foot-rope centre to the hand-rope centre at a plank gap
Even shaping terms / mirror symmetrynothe laid rope is chiral: a right-hand lay mirrors to a left-hand one, so the body has no mirror partner by design
Wrappers follow the host's profileyeslashings take the lathe profile's radius at their height
Seat conformance (banded)yesplank seats and lashing hoops, both banded (--float-planks, --loose-lashings)
Plumb and real-world sizeyesposts and stakes plumb (--lean-post); rail height (--slack-rails)
Band hooped, never flushyeslashings bite 2.5 mm
Member tenoned into its seatyessills end at the post centres
Segment counts are a silhouette budgetyes16-segment posts, 12-segment stakes, smooth-shaded
Material face floorsyestimber ≥ 1100, rope ≥ 2800
Shading is part of the modelyesposts, stakes and ropes smooth (round, organic); planks and sills flat (sawn)
One substance, one slotyestimber (posts, sills, planks, stakes), rope
Edge treatment: no right anglesyesexit 20 (--sharp-plank)
Sort bmesh operator inputsyesbevel edges sorted by index
Variation into surface, never functionyesplank length, tone and post wobble vary; plank pitch is asserted (--drift-planks)
A platform bears on somethingyesevery plank seats on both foot ropes, counted
One connected assemblyyesexit 18 (--float-suspenders)
Bake texels per UV cellyesexit 21 (--low-bake)
Bake cage narrower than the nearest neighbouryesCAGE_EXTRUSION 0.01 m
Rope is laid, not pipedyesabove
Identical boards read as CGyesPlankTone and GrainDir per shell
Level on the stage; stage 60 myesturned about Z only; 60 m floor and wall
Keep a falsifier's envelope stillyesworst deltas: --float-post +12 mm Z, --loose-lashings +13 mm X/Y, against 20 mm
Masonry, vessels, scatter, fixtures, roofs, forgings, rings, iron, paintnothe piece has no stone, vessel, scatter, plate, roof, forging, ring hardware or paint

Falsifiers#

Each breaks one pipeline stage so a named budget fails. All fourteen were run on 4.5.11, 5.1.2 and 5.2.1 and exited the same declared code on all three.

FlagTarget budgetBreaksExit
--skip-decimateLOD1 ratiodrops the DECIMATE modifiers, LOD1 ratio goes to 1.00009
--stray-vertmesh hygieneadds one loose vertex above the deck, inside the envelope15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--float-postnamed supportsfloats one post 12 mm; the others still ground the AABB16
--short-railsjoint bitestops each hand rope 20 mm short of its post; deepest end vertex −19.3 mm17
--float-planksplank seatlifts every plank 7 mm off the ropes; bite −3.0 mm18
--loose-lashingslashing hoopsizes every lashing 6.5 mm wider; bite −4.0 mm18
--float-suspendersone connected assemblystops both ends of every suspender 30 mm short; 15 components18
--lean-postplumbleans one post 20 mm at the top, lashings and all; 19.4 mm off plumb19
--vee-deckdeck parabolatwo straight ramps with the same ends and sag; 32.5 mm off the fit19
--drift-planksplank pitchshifts alternate planks ±15 mm along the rope; 31.7 mm off the mean gap19
--slack-railsrail heighthangs the hand ropes 0.40 m instead of 0.10 m; rail 0.565 m19
--sharp-plankedge treatmentleaves the middle plank unchamfered; 12 right-angle edges20
--low-bakebaked texels per UV islandbakes at 256 px; 9.42 texels21

Exit codes#

File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check.

CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh did not build, or has no UV layer
4Base triangle count outside band
5Material slots, or a material's face floor
6UVs outside 0..1
7UV AABB overlap above tolerance
8Outer AABB off declared size
9LOD1 or LOD2 ratio outside band (--skip-decimate)
10Framing gate (examples/gallery_framing.py, render path only)
11Collider triangles above ceiling
12Normal bake failed or produced no image data
13glTF export missing or empty
14--output produced no file
15Mesh hygiene (--stray-vert)
16Grounded zmin, or a named support floating (--lift-z, --float-post)
17A rope end or sill tenon not biting its host (--short-rails)
18Plank seat, lashing hoop, or the contact graph (--float-planks, --loose-lashings, --float-suspenders)
19Plumb, deck parabola, plank pitch or rail height (--lean-post, --vee-deck, --drift-planks, --slack-rails)
20Right-angle edges (--sharp-plank)
21Baked texels per UV island below floor (--low-bake)

Run it#

# Budget check, no render. ~2.2 s on 4.5, ~2.0 s on 5.1, ~2.2 s on 5.2.
blender --background --python rope_bridge.py --

# Falsifier: the deck stops hanging as a parabola. Must exit 19.
blender --background --python rope_bridge.py -- --vee-deck

# Falsifier: every suspender stops short of both ropes. Must exit 18.
blender --background --python rope_bridge.py -- --float-suspenders

# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
blender --background --python rope_bridge.py -- --output bridge.webp

Smoke runs the check-only path. It does not pass --output or any falsifier.

Cross-version measurements#

Value4.5.115.1.25.2.1
Base triangles876887688768
LOD1 tris / ratio4384 / 0.5000samesame
LOD2 tris / ratio1928 / 0.2199samesame
Face counts (timber / rope)1356 / 3348samesame
Outer AABB4.6185 × 1.0733 × 1.4800samesame
Collider tris360360360
Deck fit residual / sag0.00297 / 0.22037samesame
glTF bytes300212300212300212
Check wall-clock~2.2 s~2.0 s~2.2 s

DECIMATE COLLAPSE is the usual cross-version suspect. Here it happens to produce identical LOD counts on all three binaries; the gate is still a ratio band, not an exact count.

Source

showcase/rope-bridge/rope_bridge.py 1842 lines · View on GitHub →
"""Game-ready rope bridge — a showcase piece, not an example.

Asserts budget conformance of a procedural rope footbridge: two log posts
and a sill at each end, twenty planks laid on two foot ropes that hang
between the sills, two hand ropes lashed to the posts, vertical ropes
tying hand rope to foot rope, and foot ropes run back over the sills to
ground stakes. Carried through UVs, two materials (timber, rope), a
high-to-low normal bake, an LOD chain, a compound box collider, and a
Unity glTF export.

The budget that matters here is the one a rope bridge can fail
invisibly: the deck must hang as a parabola between the sills — the
shape a uniformly loaded cable takes — recomputed by a least-squares fit
to the plank top faces rather than from the function the generator
used. Two straight ramps meeting at midspan have the same ends, the same
sag and the same bounding box; only the fit knows the difference.

Budgets are declared below and recomputed from the generated result.
They are not API-contract witnesses. Each falsifier violates one named
budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
hygiene, ``--lift-z`` grounded zmin, ``--float-post`` the named
supports, ``--short-rails`` the rope-end joint bite, ``--float-planks``
the plank seat, ``--loose-lashings`` the lashing hoop,
``--float-suspenders`` the one-assembly contact graph, ``--lean-post``
plumb, ``--vee-deck`` the deck parabola, ``--drift-planks`` the plank
pitch, ``--slack-rails`` the rail height, ``--sharp-plank`` the edge
treatment, ``--low-bake`` the baked texels per UV cell.

Fixed seed 31 for plank lengths, post wobble and tone. DECIMATE
COLLAPSE triangle counts are not byte-identical across Blender versions
— the LOD gate is a ratio band, not an exact count.

    blender --background --python rope_bridge.py --
    blender --background --python rope_bridge.py -- --vee-deck
    blender --background --python rope_bridge.py -- --output bridge.png
"""
import argparse
import math
import os
import random
import sys
import tempfile
import traceback

import bmesh
import bpy
from mathutils import Matrix, Vector
from mathutils.bvhtree import BVHTree
from mathutils.kdtree import KDTree

_REPO = os.path.abspath(
    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
)
sys.path.insert(0, os.path.join(_REPO, "examples"))
sys.dont_write_bytecode = True
import gallery_framing  # noqa: E402

# A 3.6 m span between post centres, 0.8 m planks: a garden or gorge
# footbridge module. X runs along the span, Y across it, Z up.
SPAN = 3.60
HALF = SPAN / 2.0
BRIDGE_SEED = 31

# Log posts, tapered and plumb, carrying the hand ropes.
POST_Y = 0.46
POST_H = 1.48
POST_R_BOT = 0.068
POST_R_TOP = 0.060
POST_SEG = 16
POST_WOBBLE = 0.03
# Sills: a squared beam across each end, through-tenoned to the post
# centres. The foot ropes ride over the top of it.
SILL_W = 0.10
SILL_H = 0.10
SILL_TOP = 0.50
# Ground stakes behind each end, where the foot ropes are tied off.
STAKE_OUT = 0.45
STAKE_R = 0.042
STAKE_H = 0.36
STAKE_SEG = 12
STAKE_TIE = 0.20

# Ropes are laid, not piped: a three-lobed section turned one vertex step
# per ring, so each lobe winds along the path as a strand.
ROPE_PIPE = 9
ROPE_LOBE = 0.20
THIN_PIPE = 6
THIN_LOBE = 0.15
FOOT_Y = 0.33
FOOT_R = 0.017
# The foot rope bites the sill top; below the lobe spread, so it never
# floats over a trough in the lay.
FOOT_BITE = 0.006
HAND_R = 0.016
HAND_Z = 1.30
HAND_SAG = 0.10
SUSP_R = 0.008
SUSP_RINGS = 6
# Suspenders tie every third plank gap, symmetric about midspan.
SUSP_EVERY = 3
LASH_R = 0.010
LASH_BITE = 0.0025
LASH_TURNS = 2
LASH_PITCH = 0.021
POST_LASH_SEG = 14
STAKE_LASH_SEG = 12
# A rope end finishes in a short blunt cone this fraction of its radius
# long, so every end is closed without a fan cap.
CONE = 0.5

# The deck: planks on the foot ropes, pitched evenly along the rope.
DECK_SAG = 0.22
PLANK_N = 20
PLANK_W = 0.13
PLANK_T = 0.030
PLANK_L = 0.80
PLANK_L_JITTER = 0.015
# The first and last plank centres, clear of the sill's inner face.
DECK_END = HALF - 0.20
# Each plank's underside is set from the rope's own reach at its station,
# so the bite is exact whatever the lay does under it.
PLANK_BITE = 0.004
CHAMFER = 0.005

# Parabola half-span: the foot rope reaches sill height at the sill's
# inner face and runs level over it.
DECK_HP = HALF - SILL_W / 2.0
FOOT_Z_END = SILL_TOP + FOOT_R - FOOT_BITE

BBOX_TOL = 0.020
OUTER_SIZE = (4.619, 1.073, 1.480)

BASE_TRIS_MIN = 8300
BASE_TRIS_MAX = 9200
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
LOD2_RATIO_MAX = 0.35
LOD1_TARGET = 0.50
LOD2_TARGET = 0.22
MATERIAL_COUNT = 2
TIMBER_FACES_MIN = 1100
ROPE_FACES_MIN = 2800
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
COLLIDER_TRIS_MAX = 400
BAKE_RES = 512
LOW_BAKE_RES = 256
CAGE_EXTRUSION = 0.01
# A UV cell narrower than this many baked texels reads its neighbour's
# normals across the border under bilinear lookup.
TEXELS_PER_CELL_MIN = 12.0
ZMIN_EPS = 1e-4
DOUBLES_EPS = 1e-5
AREA_EPS = 1e-10
COPLANAR_NORMAL_EPS = 1e-4
COPLANAR_PLANE_EPS = 1e-4
COPLANAR_CENTRE_MAX = 0.05
RIGHT_ANGLE_TOL = math.radians(5.0)
LIFT_Z = 0.05

SUPPORTS = 8
SUPPORT_Z_MAX = 1e-3
FLOAT_POST_LIFT = 0.012
# Joint bites, as the deepest member vertex inside the host's surface.
RAIL_EMBED_MIN = 0.030
FOOT_EMBED_MIN = 0.020
SILL_EMBED_MIN = 0.010
SHORT_RAIL_GAP = 0.020
# Plank seat on each foot rope, measured against the plank's own underside.
PLANK_BITE_MIN = 0.002
PLANK_BITE_MAX = 0.008
FLOAT_PLANK_LIFT = 0.007
# Lashing hoop: how far each turn bites into its host.
LASH_BITE_MIN = 0.0005
LASH_BITE_MAX = 0.0050
LOOSE_LASH_BITE = -0.004
FLOAT_SUSP_PULL = 0.030
# Plumb: bottom-slab and top-slab centroids of each post and stake.
PLUMB_TOL = 0.004
LEAN_TOP = 0.020
# Deck parabola: every plank top centre within this of the fitted
# parabola, and the fitted sag within SAG_TOL of DECK_SAG.
CURVE_TOL = 0.006
SAG_TOL = 0.015
# Plank pitch: every gap between neighbouring plank tops within this of
# the mean. Feet find planks blind; spacing is function, not surface.
PITCH_TOL = 0.003
DRIFT_PLANK = 0.015
# Rail height: hand rope centre above the deck top at midspan.
RAIL_H_MIN = 0.80
RAIL_H_MAX = 1.00
SLACK_SAG = 0.40

TIMBER_IDX = 0
ROPE_IDX = 1
PLANK_TONE_JITTER = 0.26
WOOD_GRAIN_SCALE = 42.0


def eevee_engine_id():
    """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5."""
    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"


def fail(msg, code):
    print(f"FAIL[{code}]: {msg}", file=sys.stderr)
    return code


def triangle_count(mesh):
    mesh.calc_loop_triangles()
    return len(mesh.loop_triangles)


def evaluated_triangle_count(obj):
    deps = bpy.context.evaluated_depsgraph_get()
    ev = obj.evaluated_get(deps)
    mesh = ev.to_mesh()
    try:
        mesh.calc_loop_triangles()
        return len(mesh.loop_triangles)
    finally:
        ev.to_mesh_clear()


# --- curves -----------------------------------------------------------------


def deck_z(x, vee=False):
    """Foot-rope centre height over the deck: a parabola between the sills.

    A uniformly loaded cable hangs as a parabola. ``vee`` is the falsifier:
    two straight ramps with the same ends and the same sag.
    """
    u = min(1.0, abs(x) / DECK_HP)
    if vee:
        return FOOT_Z_END - DECK_SAG * (1.0 - u)
    return FOOT_Z_END - DECK_SAG * (1.0 - u * u)


def deck_slope(x, vee=False):
    if abs(x) >= DECK_HP:
        return 0.0
    if vee:
        return math.copysign(DECK_SAG / DECK_HP, x) if x else 0.0
    return 2.0 * DECK_SAG * x / (DECK_HP * DECK_HP)


def catenary_a(half, sag):
    """Catenary parameter whose sag over ``half`` is ``sag``, by bisection."""
    lo, hi = 0.05, 200.0
    for _ in range(200):
        mid = 0.5 * (lo + hi)
        if mid * (math.cosh(half / mid) - 1.0) > sag:
            lo = mid
        else:
            hi = mid
    return 0.5 * (lo + hi)


def hand_z(x, a, sag):
    """Hand-rope centre: a free catenary from post to post."""
    return HAND_Z - sag + a * (math.cosh(x / a) - 1.0)


def deck_stations(vee=False):
    """Plank centres, evenly pitched by arc length along the foot rope."""
    n = 4000
    xs = [-DECK_END + 2.0 * DECK_END * i / n for i in range(n + 1)]
    s = [0.0]
    for i in range(n):
        dz = deck_z(xs[i + 1], vee) - deck_z(xs[i], vee)
        s.append(s[-1] + math.hypot(xs[i + 1] - xs[i], dz))
    out = []
    j = 0
    for k in range(PLANK_N):
        target = s[-1] * k / (PLANK_N - 1)
        while j < n and s[j + 1] < target:
            j += 1
        seg = s[j + 1] - s[j] if j < n else 1.0
        f = (target - s[j]) / seg if seg > 0 else 0.0
        out.append(xs[j] + f * (xs[min(j + 1, n)] - xs[j]))
    out[0], out[-1] = -DECK_END, DECK_END
    return out


# --- construction -----------------------------------------------------------


def ring_offsets(t, side, r, n, lobe, idx, angle0=0.0):
    """Section offsets for ring ``idx``: a lobed circle, turned per ring.

    The frame's side vector is fixed to the path's plane, so the lay never
    jumps where the path turns. The lobe phase advances one vertex step per
    ring, which winds each lobe along the path as a strand.
    """
    s = side - t * side.dot(t)
    s.normalize()
    up = t.cross(s)
    out = []
    for k in range(n):
        a = 2.0 * math.pi * k / n + angle0
        rr = r
        if lobe:
            rr = r * (1.0 + lobe * math.cos(3.0 * a - 2.0 * math.pi * 3.0 * idx / n))
        out.append(s * (rr * math.cos(a)) + up * (rr * math.sin(a)))
    return out


def path_tangents(pts, closed=False):
    m = len(pts)
    out = []
    for i in range(m):
        if closed:
            d = pts[(i + 1) % m] - pts[(i - 1) % m]
        elif i == 0:
            d = pts[1] - pts[0]
        elif i == m - 1:
            d = pts[-1] - pts[-2]
        else:
            d = pts[i + 1] - pts[i - 1]
        out.append(d.normalized())
    return out


def sweep(bm, pts, r, n, lobe, side, mat_idx, strips, closed=False, angle0=0.0):
    """Loft a laid rope along ``pts``; open ends close in a blunt cone.

    Returns the shell's vertices. UVs are recorded per face in ``strips``
    as one strip island: arc length along, section angle around, so the
    faces of one rope never overlap each other in UV. ``angle0`` turns the
    section, so two turns of one lashing are not the same section stacked,
    whose outer faces would share planes.
    """
    pts = [Vector(p) for p in pts]
    tans = path_tangents(pts, closed)
    rings = []
    for i, (p, t) in enumerate(zip(pts, tans)):
        offs = ring_offsets(t, side, r, n, lobe, i, angle0)
        rings.append([bm.verts.new(p + o) for o in offs])
    s = [0.0]
    for i in range(1, len(pts)):
        s.append(s[-1] + (pts[i] - pts[i - 1]).length)
    if closed:
        s.append(s[-1] + (pts[0] - pts[-1]).length)
    island = len({v[0] for v in strips.values()})
    m = len(rings)
    spans = m if closed else m - 1
    for k in range(spans):
        a, b = rings[k], rings[(k + 1) % m]
        for i in range(n):
            j = (i + 1) % n
            f = bm.faces.new((a[i], a[j], b[j], b[i]))
            f.material_index = mat_idx
            f.smooth = True
            strips[f] = (island, {
                a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n),
                b[j]: (s[k + 1], (i + 1) / n), b[i]: (s[k + 1], i / n),
            })
    if not closed:
        for ring, p, t, sign, s_end in (
            (rings[0], pts[0], tans[0], -1.0, s[0]),
            (rings[-1], pts[-1], tans[-1], 1.0, s[len(pts) - 1]),
        ):
            pole = bm.verts.new(p + t * (sign * CONE * r))
            s_pole = s_end + sign * CONE * r
            for i in range(n):
                j = (i + 1) % n
                f = bm.faces.new((pole, ring[i], ring[j]))
                f.material_index = mat_idx
                f.smooth = True
                strips[f] = (island, {
                    pole: (s_pole, (i + 0.5) / n),
                    ring[i]: (s_end, i / n), ring[j]: (s_end, (i + 1) / n),
                })
    return [v for ring in rings for v in ring]


def lathe(bm, axis_at, profile, n, mat_idx, strips):
    """A turned log: ``profile`` is (z, r) bottom to top, r == 0 at the poles.

    ``axis_at(z)`` gives the axis's XY at height z, so a leaning post keeps
    every ring on its own axis. Smooth-shaded: a log is round, and equal
    flat facets would read as a coopered stave.
    """
    island = len({v[0] for v in strips.values()})
    s = [0.0]
    for (z0, r0), (z1, r1) in zip(profile, profile[1:]):
        s.append(s[-1] + math.hypot(z1 - z0, r1 - r0))
    rings = []
    for z, r in profile:
        cx, cy = axis_at(z)
        if r <= 0.0:
            rings.append(bm.verts.new((cx, cy, z)))
            continue
        rings.append([
            bm.verts.new((cx + r * math.cos(2.0 * math.pi * k / n),
                          cy + r * math.sin(2.0 * math.pi * k / n), z))
            for k in range(n)
        ])
    for k in range(len(rings) - 1):
        a, b = rings[k], rings[k + 1]
        for i in range(n):
            j = (i + 1) % n
            if isinstance(a, list) and isinstance(b, list):
                vs = (a[i], a[j], b[j], b[i])
                uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n),
                      b[j]: (s[k + 1], (i + 1) / n), b[i]: (s[k + 1], i / n)}
            elif isinstance(b, list):
                vs = (a, b[j], b[i])
                uv = {a: (s[k], (i + 0.5) / n), b[j]: (s[k + 1], (i + 1) / n),
                      b[i]: (s[k + 1], i / n)}
            else:
                vs = (a[i], a[j], b)
                uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n),
                      b: (s[k + 1], (i + 0.5) / n)}
            f = bm.faces.new(vs)
            f.material_index = mat_idx
            f.smooth = True
            strips[f] = (island, uv)


def profile_radius(profile, z):
    """The lathe's radius at height z, linear between rings as the mesh is."""
    body = [(pz, pr) for pz, pr in profile if pr > 0.0]
    for (z0, r0), (z1, r1) in zip(body, body[1:]):
        if z0 <= z <= z1 and z1 > z0:
            return r0 + (r1 - r0) * (z - z0) / (z1 - z0)
    return body[-1][1]


def add_box(bm, centre, ax, ay, az, size, mat_idx):
    """A box on explicit axes; ``size`` is the full extent along each."""
    geo = bmesh.ops.create_cube(bm, size=1.0)
    for v in geo["verts"]:
        c = v.co.copy()
        v.co = centre + ax * (c.x * size[0]) + ay * (c.y * size[1]) + az * (c.z * size[2])
    for f in {f for v in geo["verts"] for f in v.link_faces}:
        f.material_index = mat_idx
        f.smooth = False
    return geo["verts"]


def pack_uvs(bm, strips, margin=0.08):
    """One grid cell per UV island.

    A rope, post or stake is one strip island (arc length by section
    angle), recorded in ``strips`` as it was built; every other face is
    its own planar island. Cells are sized from the island count, which is
    what the baked-texel budget reads back.
    """
    uv = bm.loops.layers.uv.new("UVMap")
    bm.faces.index_update()
    islands = {}
    order = []
    for face in bm.faces:
        key = ("s", strips[face][0]) if face in strips else ("f", face.index)
        if key not in islands:
            islands[key] = []
            order.append(key)
        islands[key].append(face)
    cols = max(1, math.ceil(math.sqrt(len(order))))
    rows = max(1, math.ceil(len(order) / cols))
    cell_w, cell_h = 1.0 / cols, 1.0 / rows
    pad_u, pad_v = margin * cell_w * 0.5, margin * cell_h * 0.5
    usable_w, usable_h = cell_w - 2.0 * pad_u, cell_h - 2.0 * pad_v
    for idx, key in enumerate(order):
        faces = islands[key]
        coords = {}
        for face in faces:
            if face in strips:
                m = strips[face][1]
                coords[face] = [m[loop.vert] for loop in face.loops]
                continue
            nrm = face.normal
            ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z)
            pts = []
            for loop in face.loops:
                co = loop.vert.co
                if az >= ax and az >= ay:
                    pts.append((co.x, co.y))
                elif ax >= ay:
                    pts.append((co.y, co.z))
                else:
                    pts.append((co.x, co.z))
            coords[face] = pts
        allc = [c for cs in coords.values() for c in cs]
        minx = min(c[0] for c in allc)
        maxx = max(c[0] for c in allc)
        miny = min(c[1] for c in allc)
        maxy = max(c[1] for c in allc)
        dx = max(maxx - minx, 1e-8)
        dy = max(maxy - miny, 1e-8)
        ou = (idx % cols) * cell_w + pad_u
        ov = (idx // cols) * cell_h + pad_v
        for face in faces:
            for loop, (x, y) in zip(face.loops, coords[face]):
                loop[uv].uv = (
                    ou + (x - minx) / dx * usable_w,
                    ov + (y - miny) / dy * usable_h,
                )


def build_bridge_mesh(
    name,
    vee_deck=False,
    float_post=False,
    short_rails=False,
    float_planks=False,
    loose_lashings=False,
    float_suspenders=False,
    lean_post=False,
    drift_planks=False,
    slack_rails=False,
    sharp_plank=False,
    boxes=None,
):
    """The whole bridge in one bmesh. ``boxes`` collects collider boxes."""
    rng = random.Random(BRIDGE_SEED)
    hand_sag = SLACK_SAG if slack_rails else HAND_SAG
    cat_a = catenary_a(HALF, hand_sag)
    stations = deck_stations(vee_deck)
    gaps = [0.5 * (a + b) for a, b in zip(stations, stations[1:])]
    susp_x = [g for j, g in enumerate(gaps) if j % SUSP_EVERY == 0]
    strips = {}
    Y = Vector((0.0, 1.0, 0.0))
    X = Vector((1.0, 0.0, 0.0))
    Z = Vector((0.0, 0.0, 1.0))

    # Foot-rope path, -X to +X: stake, down-line, over the sill's outer
    # arris, level across the sill, the deck parabola, and back out.
    deck_x = sorted(set(
        [round(x, 9) for x in stations + gaps]
        + [-DECK_HP, DECK_HP, -0.5 * (DECK_HP + DECK_END), 0.5 * (DECK_HP + DECK_END)]
    ))

    def tail(sign):
        corner = (sign * (HALF + SILL_W / 2.0), SILL_TOP)
        stake = (sign * (HALF + STAKE_OUT), STAKE_TIE)
        rho = FOOT_R - FOOT_BITE
        dx, dz = stake[0] - corner[0], stake[1] - corner[1]
        dist = math.hypot(dx, dz)
        # The down-line leaves the arris on a tangent to the rope's bend.
        base = math.atan2(dz, dx)
        phi = base + sign * math.acos(rho / dist)
        start = math.pi / 2.0
        # Turn the short way round the arris, not through the sill.
        while phi - start > math.pi:
            phi -= 2.0 * math.pi
        while phi - start < -math.pi:
            phi += 2.0 * math.pi
        # One mitred ring on the bisector. The bend radius is smaller than
        # the rope, so a ring per few degrees of arc folds the section
        # through itself and leaves right-angle creases on the outside.
        half_turn = 0.5 * abs(phi - start)
        mid_ang = 0.5 * (start + phi)
        reach = rho / math.cos(half_turn)
        pts = [(corner[0] + reach * math.cos(mid_ang), corner[1] + reach * math.sin(mid_ang))]
        tx = corner[0] + rho * math.cos(phi)
        tz = corner[1] + rho * math.sin(phi)
        for k in (1, 2, 3):
            f = k / 4.0
            pts.append((tx + (stake[0] - tx) * f, tz + (stake[1] - tz) * f))
        pts.append(stake)
        return pts

    right = tail(1.0)
    left = [p for p in reversed(tail(-1.0))]
    mid = [(x, deck_z(x, vee_deck)) for x in deck_x]
    foot_xz = left + mid + right
    foot_idx = {round(x, 9): len(left) + k for k, x in enumerate(deck_x)}
    foot_pts = [Vector((x, 0.0, z)) for x, z in foot_xz]
    foot_tans = path_tangents(foot_pts)

    # Planks: each one's underside set from the rope's own reach along the
    # plank normal at its station, less the bite.
    planks = []
    for i, x in enumerate(stations):
        k = foot_idx[round(x, 9)]
        t = foot_tans[k]
        nrm = t.cross(Y).normalized()
        if nrm.z < 0.0:
            nrm = -nrm
        reach = max(o.dot(nrm) for o in ring_offsets(t, Y, FOOT_R, ROPE_PIPE, ROPE_LOBE, k))
        lift = reach - PLANK_BITE + PLANK_T / 2.0
        if float_planks:
            lift += FLOAT_PLANK_LIFT
        centre = foot_pts[k] + nrm * lift
        if drift_planks:
            centre = centre + t * (DRIFT_PLANK if i % 2 else -DRIFT_PLANK)
        length = PLANK_L + rng.uniform(-PLANK_L_JITTER, PLANK_L_JITTER)
        planks.append((centre, t, nrm, length))

    bm = bmesh.new()
    try:
        bevel_edges = []
        for i, (centre, t, nrm, length) in enumerate(planks):
            vs = add_box(bm, centre, t, Y, nrm, (PLANK_W, length, PLANK_T), TIMBER_IDX)
            if boxes is not None:
                boxes.append((centre, t, Y, nrm, (PLANK_W, length, PLANK_T)))
            if not (sharp_plank and i == PLANK_N // 2):
                bevel_edges.extend({e for v in vs for e in v.link_edges})
        for sign in (-1.0, 1.0):
            c = Vector((sign * HALF, 0.0, SILL_TOP - SILL_H / 2.0))
            vs = add_box(bm, c, X, Y, Z, (SILL_W, 2.0 * POST_Y, SILL_H), TIMBER_IDX)
            if boxes is not None:
                boxes.append((c, X, Y, Z, (SILL_W, 2.0 * POST_Y, SILL_H)))
            bevel_edges.extend({e for v in vs for e in v.link_edges})
        bm.edges.index_update()
        bevel_edges = sorted(set(bevel_edges), key=lambda e: e.index)
        if bevel_edges:
            bmesh.ops.bevel(
                bm,
                geom=bevel_edges,
                offset=CHAMFER,
                segments=1,
                profile=0.5,
                affect="EDGES",
                clamp_overlap=True,
                material=TIMBER_IDX,
            )
        for f in bm.faces:
            f.smooth = False

        # Posts and stakes: tapered, faintly wobbled logs, plumb unless the
        # falsifier leans one. Every ring sits on the post's own axis.
        posts = []
        for sx in (-1.0, 1.0):
            for sy in (-1.0, 1.0):
                cx, cy = sx * HALF, sy * POST_Y
                lift = FLOAT_POST_LIFT if (float_post and sx < 0 and sy < 0) else 0.0
                lean = LEAN_TOP if (lean_post and sx > 0 and sy < 0) else 0.0
                w = 1.0 + rng.uniform(-POST_WOBBLE, POST_WOBBLE)

                def r_at(z):
                    return POST_R_BOT + (POST_R_TOP - POST_R_BOT) * z / POST_H

                prof = [
                    (0.0, 0.0), (0.0, 0.86 * r_at(0.0)), (0.014, r_at(0.014)),
                    (0.5 * POST_H, r_at(0.5 * POST_H) * w),
                    (POST_H - 0.044, r_at(POST_H - 0.044)),
                    # A two-step round-over: one ring left the dome faceted.
                    (POST_H - 0.018, 0.90 * r_at(POST_H)),
                    (POST_H - 0.004, 0.56 * r_at(POST_H)), (POST_H, 0.0),
                ]
                prof = [(z + lift, r) for z, r in prof]

                def axis_at(z, cx=cx, cy=cy, lean=lean):
                    return cx + lean * z / POST_H, cy

                lathe(bm, axis_at, prof, POST_SEG, TIMBER_IDX, strips)
                posts.append((sx, sy, axis_at, prof))
                if boxes is not None:
                    r0 = POST_R_BOT
                    boxes.append((Vector((cx, cy, lift + POST_H / 2.0)), X, Y, Z,
                                  (2.0 * r0, 2.0 * r0, POST_H)))
        stakes = []
        for sx in (-1.0, 1.0):
            for sy in (-1.0, 1.0):
                cx, cy = sx * (HALF + STAKE_OUT), sy * FOOT_Y
                w = 1.0 + rng.uniform(-POST_WOBBLE, POST_WOBBLE)
                prof = [
                    (0.0, 0.0), (0.0, 0.88 * STAKE_R), (0.012, STAKE_R),
                    (0.5 * STAKE_H, STAKE_R * w), (STAKE_H - 0.030, STAKE_R),
                    (STAKE_H - 0.012, 0.88 * STAKE_R),
                    (STAKE_H - 0.003, 0.54 * STAKE_R), (STAKE_H, 0.0),
                ]

                def axis_at(z, cx=cx, cy=cy):
                    return cx, cy

                lathe(bm, axis_at, prof, STAKE_SEG, TIMBER_IDX, strips)
                stakes.append((sx, sy, axis_at, prof))
                if boxes is not None:
                    boxes.append((Vector((cx, cy, STAKE_H / 2.0)), X, Y, Z,
                                  (2.0 * STAKE_R, 2.0 * STAKE_R, STAKE_H)))

        # Foot ropes, both sides, on the one path.
        for sy in (-1.0, 1.0):
            pts = [Vector((p.x, sy * FOOT_Y, p.z)) for p in foot_pts]
            sweep(bm, pts, FOOT_R, ROPE_PIPE, ROPE_LOBE, Y, ROPE_IDX, strips)

        # Hand ropes: post centre to post centre, a free catenary. The
        # falsifier stops each end short of the post's surface.
        hx = sorted(set([round(x, 9) for x in deck_x] + [-HALF, HALF]))
        if short_rails:
            end = HALF - POST_R_BOT - SHORT_RAIL_GAP
            hx = [x for x in hx if abs(x) < end] + [-end, end]
            hx = sorted(hx)
        for sy in (-1.0, 1.0):
            pts = [Vector((x, sy * POST_Y, hand_z(x, cat_a, hand_sag))) for x in hx]
            sweep(bm, pts, HAND_R, ROPE_PIPE, ROPE_LOBE, Y, ROPE_IDX, strips)

        # Suspenders: foot-rope centre to hand-rope centre at every third
        # plank gap, so each end is buried in the rope it ties.
        pull = FLOAT_SUSP_PULL if float_suspenders else 0.0
        for sy in (-1.0, 1.0):
            for x in susp_x:
                a = Vector((x, sy * FOOT_Y, deck_z(x, vee_deck)))
                b = Vector((x, sy * POST_Y, hand_z(x, cat_a, hand_sag)))
                d = (b - a).normalized()
                a, b = a + d * pull, b - d * pull
                pts = [a + (b - a) * (k / (SUSP_RINGS - 1)) for k in range(SUSP_RINGS)]
                sweep(bm, pts, SUSP_R, THIN_PIPE, THIN_LOBE, X, ROPE_IDX, strips)

        # Lashings: turns hooped onto the host, the inner radius a named
        # bite inside the host's own radius at that height.
        bite = LOOSE_LASH_BITE if loose_lashings else LASH_BITE

        def lash(axis_at, prof, z, seg, angle0=0.0):
            cx, cy = axis_at(z)
            rm = profile_radius(prof, z) - bite + LASH_R
            pts = [
                Vector((cx + rm * math.cos(2.0 * math.pi * k / seg),
                        cy + rm * math.sin(2.0 * math.pi * k / seg), z))
                for k in range(seg)
            ]
            sweep(bm, pts, LASH_R, THIN_PIPE, THIN_LOBE, Z, ROPE_IDX, strips,
                  closed=True, angle0=angle0)

        for _sx, _sy, axis_at, prof in posts:
            z0 = HAND_Z - 0.5 * (LASH_TURNS - 1) * LASH_PITCH
            for k in range(LASH_TURNS):
                # Each turn's section half a vertex step round from the last.
                lash(axis_at, prof, z0 + k * LASH_PITCH, POST_LASH_SEG,
                     angle0=k * math.pi / THIN_PIPE)
        for _sx, _sy, axis_at, prof in stakes:
            lash(axis_at, prof, STAKE_TIE, STAKE_LASH_SEG)

        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
        pack_uvs(bm, strips)
        me = bpy.data.meshes.new(name)
        bm.to_mesh(me)
        me.update()
    finally:
        bm.free()
    paint_planks(me)
    obj = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(obj)
    return obj


# --- surface ----------------------------------------------------------------


def _long_axis(pts):
    """Principal axis of a point set, by power iteration on its covariance."""
    c = sum(pts, Vector()) / len(pts)
    cov = [[0.0] * 3 for _ in range(3)]
    for p in pts:
        d = p - c
        for i in range(3):
            for j in range(3):
                cov[i][j] += d[i] * d[j]
    v = Vector((1.0, 0.3, 0.1))
    for _ in range(30):
        w = Vector([sum(cov[i][j] * v[j] for j in range(3)) for i in range(3)])
        if w.length < 1e-12:
            break
        v = w.normalized()
    return v


def paint_planks(me):
    """Per-shell ``PlankTone`` and ``GrainDir`` face attributes.

    Twenty planks cut from one material are one plank repeated. Each shell
    gets a seeded tone and grain along its own long axis: across the deck
    on a plank, up a post.
    """
    tone = [0.5] * len(me.polygons)
    grain = [(0.0, 0.0, 1.0)] * len(me.polygons)
    owner = {}
    rng = random.Random(BRIDGE_SEED * 17)
    for g in shells(me):
        pts = [me.vertices[i].co.copy() for i in g]
        d = _long_axis(pts) if len(pts) > 2 else Vector((0.0, 0.0, 1.0))
        t = 0.5 + rng.uniform(-PLANK_TONE_JITTER, PLANK_TONE_JITTER)
        for i in g:
            owner[i] = (t, tuple(d))
    for poly in me.polygons:
        t, d = owner[poly.vertices[0]]
        tone[poly.index] = t
        grain[poly.index] = d
    a = me.attributes.new("PlankTone", "FLOAT", "FACE")
    a.data.foreach_set("value", tone)
    b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE")
    b.data.foreach_set("vector", [c for v in grain for c in v])


def _sock(sockets, identifier):
    """A Mix-node socket by identifier; its A/B/Result names repeat per type."""
    return next(sk for sk in sockets if sk.identifier == identifier)


def wood_material(name):
    """Grain along each member (``GrainDir``), tone per member (``PlankTone``)."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    coord = nt.nodes.new("ShaderNodeTexCoord")
    gdir = nt.nodes.new("ShaderNodeAttribute")
    gdir.attribute_name = "GrainDir"
    tone = nt.nodes.new("ShaderNodeAttribute")
    tone.attribute_name = "PlankTone"
    dot = nt.nodes.new("ShaderNodeVectorMath")
    dot.operation = "DOT_PRODUCT"
    nt.links.new(coord.outputs["Object"], dot.inputs[0])
    nt.links.new(gdir.outputs["Vector"], dot.inputs[1])
    squash = nt.nodes.new("ShaderNodeMath")
    squash.operation = "MULTIPLY"
    squash.inputs[1].default_value = 0.94
    nt.links.new(dot.outputs["Value"], squash.inputs[0])
    along = nt.nodes.new("ShaderNodeVectorMath")
    along.operation = "SCALE"
    nt.links.new(gdir.outputs["Vector"], along.inputs[0])
    nt.links.new(squash.outputs["Value"], along.inputs["Scale"])
    grain_co = nt.nodes.new("ShaderNodeVectorMath")
    grain_co.operation = "SUBTRACT"
    nt.links.new(coord.outputs["Object"], grain_co.inputs[0])
    nt.links.new(along.outputs["Vector"], grain_co.inputs[1])
    shift = nt.nodes.new("ShaderNodeVectorMath")
    shift.operation = "ADD"
    nt.links.new(grain_co.outputs["Vector"], shift.inputs[0])
    nt.links.new(tone.outputs["Fac"], shift.inputs[1])
    noise = nt.nodes.new("ShaderNodeTexNoise")
    noise.inputs["Scale"].default_value = WOOD_GRAIN_SCALE
    noise.inputs["Detail"].default_value = 6.0
    noise.inputs["Roughness"].default_value = 0.62
    nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.30
    ramp.color_ramp.elements[0].color = (0.11, 0.060, 0.030, 1.0)
    ramp.color_ramp.elements[1].position = 0.72
    ramp.color_ramp.elements[1].color = (0.34, 0.20, 0.100, 1.0)
    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
    gain = nt.nodes.new("ShaderNodeMath")
    gain.operation = "MULTIPLY_ADD"
    gain.inputs[1].default_value = 1.1
    gain.inputs[2].default_value = 0.45
    nt.links.new(tone.outputs["Fac"], gain.inputs[0])
    mix = nt.nodes.new("ShaderNodeMix")
    mix.data_type = "RGBA"
    mix.blend_type = "MULTIPLY"
    _sock(mix.inputs, "Factor_Float").default_value = 1.0
    nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color"))
    nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color"))
    nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
    rough = nt.nodes.new("ShaderNodeMapRange")
    rough.inputs["To Min"].default_value = 0.74
    rough.inputs["To Max"].default_value = 0.56
    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
    return mat


def rope_material(name):
    """Hemp: pale fibre, darker in the lay, matte, with a fine fibre bump."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    bsdf.inputs["Metallic"].default_value = 0.0
    coord = nt.nodes.new("ShaderNodeTexCoord")
    noise = nt.nodes.new("ShaderNodeTexNoise")
    noise.inputs["Scale"].default_value = 70.0
    noise.inputs["Detail"].default_value = 8.0
    noise.inputs["Roughness"].default_value = 0.6
    nt.links.new(coord.outputs["Object"], noise.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.35
    ramp.color_ramp.elements[0].color = (0.26, 0.19, 0.10, 1.0)
    ramp.color_ramp.elements[1].position = 0.68
    ramp.color_ramp.elements[1].color = (0.56, 0.45, 0.28, 1.0)
    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
    bsdf.inputs["Roughness"].default_value = 0.88
    bmp = nt.nodes.new("ShaderNodeBump")
    bmp.inputs["Strength"].default_value = 0.35
    bmp.inputs["Distance"].default_value = 0.002
    nt.links.new(noise.outputs["Fac"], bmp.inputs["Height"])
    nt.links.new(bmp.outputs["Normal"], bsdf.inputs["Normal"])
    return mat


def bridge_materials():
    """(timber, rope): shared by the check, the render and inspection."""
    return wood_material("BridgeTimber"), rope_material("BridgeRope")


def assign_slots(obj, timber, rope):
    mats = obj.data.materials
    for i, mat in enumerate((timber, rope)):
        if i < len(mats):
            mats[i] = mat
        else:
            mats.append(mat)


# --- measurement ------------------------------------------------------------


def world_bbox(obj):
    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
    xs = [c.x for c in corners]
    ys = [c.y for c in corners]
    zs = [c.z for c in corners]
    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))


def uv_stats(mesh):
    uv = mesh.uv_layers.active
    if uv is None:
        return 0.0, 0.0, 1.0, 1.0, 0.0, 0
    data = uv.data
    us = [loop.uv[0] for loop in data]
    vs = [loop.uv[1] for loop in data]
    aabbs = []
    for poly in mesh.polygons:
        pu = [data[i].uv[0] for i in poly.loop_indices]
        pv = [data[i].uv[1] for i in poly.loop_indices]
        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
    span = max(
        1e-6,
        max((a[2] - a[0]) for a in aabbs),
        max((a[3] - a[1]) for a in aabbs),
    )
    buckets = {}
    for i, a in enumerate(aabbs):
        for c in range(int(a[0] // span), int(a[2] // span) + 1):
            for r in range(int(a[1] // span), int(a[3] // span) + 1):
                buckets.setdefault((c, r), []).append(i)
    overlap = 0.0
    seen = set()
    for members in buckets.values():
        for ii in range(len(members)):
            for jj in range(ii + 1, len(members)):
                i, j = members[ii], members[jj]
                key = (i, j) if i < j else (j, i)
                if key in seen:
                    continue
                seen.add(key)
                a, b = aabbs[i], aabbs[j]
                overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max(
                    0.0, min(a[3], b[3]) - max(a[1], b[1])
                )
    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)


def uv_island_texels(mesh, res):
    """Smallest UV island extent in baked texels, islands found from the UVs.

    Two faces are one island when they share a vertex at the same UV. The
    cell size is read back from the mesh and the image, not from the grid
    the packer used.
    """
    uv = mesh.uv_layers.active
    if uv is None:
        return 0.0, 0
    parent = list(range(len(mesh.polygons)))

    def find(i):
        while parent[i] != i:
            parent[i] = parent[parent[i]]
            i = parent[i]
        return i

    first = {}
    for poly in mesh.polygons:
        for li in poly.loop_indices:
            u, v = uv.data[li].uv
            key = (mesh.loops[li].vertex_index, round(u, 6), round(v, 6))
            if key in first:
                a, b = find(first[key]), find(poly.index)
                if a != b:
                    parent[a] = b
            else:
                first[key] = poly.index
    boxes = {}
    for poly in mesh.polygons:
        root = find(poly.index)
        for li in poly.loop_indices:
            u, v = uv.data[li].uv
            b = boxes.setdefault(root, [u, v, u, v])
            b[0], b[1] = min(b[0], u), min(b[1], v)
            b[2], b[3] = max(b[2], u), max(b[3], v)
    ext = min(min(b[2] - b[0], b[3] - b[1]) for b in boxes.values())
    return ext * res, len(boxes)


def face_area(me, poly):
    idxs = poly.vertices
    if len(idxs) < 3:
        return 0.0
    v0 = me.vertices[idxs[0]].co
    area = 0.0
    for i in range(1, len(idxs) - 1):
        a = me.vertices[idxs[i]].co
        b = me.vertices[idxs[i + 1]].co
        area += (a - v0).cross(b - v0).length * 0.5
    return area


def hygiene_audit(me):
    nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons)
    ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
    zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
    bm = bmesh.new()
    try:
        bm.from_mesh(me)
        bm.verts.ensure_lookup_table()
        bm.edges.ensure_lookup_table()
        loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
        loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
        nonman = sum(1 for e in bm.edges if not e.is_manifold)
        ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
        doubles = len(ret.get("targetmap") or {})
    finally:
        bm.free()
    return {
        "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, "loose_v": loose_v,
        "loose_e": loose_e, "nonman": nonman, "zero_area": zero_area,
        "doubles": doubles,
    }


def shells(me):
    neighbors = [[] for _ in range(len(me.vertices))]
    for edge in me.edges:
        a, b = edge.vertices
        neighbors[a].append(b)
        neighbors[b].append(a)
    seen = [False] * len(me.vertices)
    groups = []
    for start in range(len(me.vertices)):
        if seen[start]:
            continue
        seen[start] = True
        stack = [start]
        group = []
        while stack:
            cur = stack.pop()
            group.append(cur)
            for nxt in neighbors[cur]:
                if not seen[nxt]:
                    seen[nxt] = True
                    stack.append(nxt)
        groups.append(group)
    return groups


def zfight_pairs(me):
    """Coplanar face pairs from *different shells* — the z-fighting budget.

    Cross-shell, not merely share-no-vertex: two faces of one post's flat
    bottom fan are coplanar by construction. Z-fighting is two separate
    bodies landing on one plane. Candidate pairs come from a KD-tree range
    query at COPLANAR_CENTRE_MAX (copied from showcase/grindstone; do not
    import across pieces).
    """
    owner = {}
    for si, g in enumerate(shells(me)):
        for vi in g:
            owner[vi] = si
    faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1))
             for p in me.polygons]
    kd = KDTree(len(faces))
    for i, (_n, c, _s) in enumerate(faces):
        kd.insert(c, i)
    kd.balance()
    hits = 0
    for i, (ni, ci, si) in enumerate(faces):
        for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX):
            if j <= i:
                continue
            nj, cj, sj = faces[j]
            if si == sj:
                continue
            if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS:
                continue
            if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS:
                continue
            hits += 1
    return hits


def right_angle_edges(me):
    """Manifold edges whose two faces meet at 90 degrees.

    Every plank and sill is chamfered, the logs are lathed with a chamfer
    ring at each end, and a rope section turns 40 or 60 degrees per edge.
    An edge still at 90 is a bevel pass that was skipped.
    """
    n = 0
    bm = bmesh.new()
    try:
        bm.from_mesh(me)
        for e in bm.edges:
            if len(e.link_faces) != 2:
                continue
            if abs(e.calc_face_angle(0.0) - math.pi / 2.0) <= RIGHT_ANGLE_TOL:
                n += 1
    finally:
        bm.free()
    return n


def shell_tree(me, group):
    """A BVH for one shell."""
    bm = bmesh.new()
    try:
        bm.from_mesh(me)
        member = set(group)
        drop = [f for f in bm.faces if not all(v.index in member for v in f.verts)]
        if drop:
            bmesh.ops.delete(bm, geom=drop, context="FACES")
        if not bm.faces:
            return None
        return BVHTree.FromBMesh(bm)
    finally:
        bm.free()


def classify(me):
    """Name every shell from its material and its own extent.

    Timber: a plank or sill is long across the deck (sills sit at the post
    stations), a post is tall, a stake is short and grounded. Rope: a main
    rope runs the span (foot if it reaches down to a stake, else hand), a
    suspender is tall and thin, a lashing is a flat loop.
    """
    out = {k: [] for k in (
        "plank", "sill", "post", "stake", "foot", "hand", "susp", "lash", "other")}
    face_mat = {}
    for p in me.polygons:
        for i in p.vertices:
            face_mat.setdefault(i, p.material_index)
    for g in shells(me):
        pts = [me.vertices[i].co.copy() for i in g]
        xs, ys, zs = [p.x for p in pts], [p.y for p in pts], [p.z for p in pts]
        a = (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
        ex, ey, ez = a[3] - a[0], a[4] - a[1], a[5] - a[2]
        c = Vector(((a[0] + a[3]) / 2, (a[1] + a[4]) / 2, (a[2] + a[5]) / 2))
        rec = {"g": g, "pts": pts, "aabb": a, "c": c}
        mat = face_mat.get(g[0], -1)
        if mat == TIMBER_IDX:
            if ey > 0.5 and ez < 0.2:
                out["sill" if abs(c.x) > HALF - 0.1 else "plank"].append(rec)
            elif ez > 1.0:
                out["post"].append(rec)
            elif ez > 0.2 and max(ex, ey) < 0.2:
                out["stake"].append(rec)
            else:
                out["other"].append(rec)
        elif mat == ROPE_IDX:
            if ex > 2.0:
                out["foot" if a[2] < STAKE_H else "hand"].append(rec)
            elif ez > 0.3:
                out["susp"].append(rec)
            elif ez < 0.06:
                out["lash"].append(rec)
            else:
                out["other"].append(rec)
        else:
            out["other"].append(rec)
    out["plank"].sort(key=lambda r: r["c"].x)
    return out


def inside_depth(tree, pts):
    """Deepest point of ``pts`` inside the (convex) shell ``tree``.

    Signed by the nearest face's normal: positive inside. Negative means
    every point is outside, by at least that much.
    """
    best = -99.0
    for p in pts:
        loc, nrm, _i, dist = tree.find_nearest(p)
        if loc is None:
            continue
        d = dist if (p - loc).dot(nrm) < 0.0 else -dist
        best = max(best, d)
    return best


def nearest(recs, p):
    return min(recs, key=lambda r: (Vector((r["c"].x, r["c"].y, 0.0))
                                    - Vector((p.x, p.y, 0.0))).length)


def bridge_audit(me):
    """Supports, joint bites, seats, contact graph, plumb and the deck."""
    parts = classify(me)
    trees = {}

    def tree(rec):
        key = id(rec)
        if key not in trees:
            trees[key] = shell_tree(me, rec["g"])
        return trees[key]

    out = {
        "n": {k: len(v) for k, v in parts.items()},
        "support_worst": max(
            (r["aabb"][2] for r in parts["post"] + parts["stake"]), default=99.0),
        "n_support": len(parts["post"]) + len(parts["stake"]),
    }

    # Joint bites: every rope end and sill end deepest inside its host.
    rail = []
    for rope in parts["hand"]:
        for end in (min, max):
            x_end = end(p.x for p in rope["pts"])
            tip = [p for p in rope["pts"] if abs(p.x - x_end) < 0.10]
            post = nearest(parts["post"], Vector((x_end, rope["c"].y, 0.0)))
            rail.append(inside_depth(tree(post), tip))
    foot = []
    for rope in parts["foot"]:
        for end in (min, max):
            x_end = end(p.x for p in rope["pts"])
            tip = [p for p in rope["pts"] if abs(p.x - x_end) < 0.10]
            stake = nearest(parts["stake"], Vector((x_end, rope["c"].y, 0.0)))
            foot.append(inside_depth(tree(stake), tip))
    sill = []
    for s in parts["sill"]:
        for sy in (-1.0, 1.0):
            post = nearest(parts["post"], Vector((s["c"].x, sy * POST_Y, 0.0)))
            end = [p for p in s["pts"] if p.y * sy > 0.0]
            sill.append(inside_depth(tree(post), end))
    out["rail_embed"] = min(rail, default=-99.0)
    out["foot_embed"] = min(foot, default=-99.0)
    out["sill_embed"] = min(sill, default=-99.0)

    # Plank seats: how far each foot rope stands into the plank's
    # underside, measured in the plank's own frame at its own station.
    bites = []
    for plank in parts["plank"]:
        members = set(plank["g"])
        faces = [p for p in me.polygons if p.vertices[0] in members]
        top = max(faces, key=lambda p: (p.normal.z > 0.9, p.area))
        n = top.normal.copy()
        t = n.cross(Vector((0.0, 1.0, 0.0))).normalized()
        base = min(p.dot(n) for p in plank["pts"])
        half_w = max(abs((p - plank["c"]).dot(t)) for p in plank["pts"])
        plank["top"] = top.center.copy()
        for rope in parts["foot"]:
            under = [
                p for p in rope["pts"]
                if abs((p - plank["c"]).dot(t)) <= half_w
            ]
            bites.append(max((p.dot(n) for p in under), default=-99.0) - base)
    out["plank_bite_min"] = min(bites, default=-99.0)
    out["plank_bite_max"] = max(bites, default=99.0)

    # Lashing hoop: each turn bites its host (a post or a stake).
    hosts = parts["post"] + parts["stake"]
    lash = []
    for rec in parts["lash"]:
        host = nearest(hosts, rec["c"])
        lash.append(inside_depth(tree(host), rec["pts"]))
    out["lash_min"] = min(lash, default=-99.0)
    out["lash_max"] = max(lash, default=99.0)

    # One connected assembly: union every pair of shells whose surfaces
    # cross. Per-part budgets pass a rope resting a hair off its host.
    recs = [r for k in parts for r in parts[k]]
    parent = list(range(len(recs)))

    def find(i):
        while parent[i] != i:
            parent[i] = parent[parent[i]]
            i = parent[i]
        return i

    for i in range(len(recs)):
        ai = recs[i]["aabb"]
        for j in range(i + 1, len(recs)):
            aj = recs[j]["aabb"]
            if any(ai[k] > aj[k + 3] + 1e-4 or aj[k] > ai[k + 3] + 1e-4 for k in range(3)):
                continue
            ti, tj = tree(recs[i]), tree(recs[j])
            if ti is not None and tj is not None and ti.overlap(tj):
                a, b = find(i), find(j)
                if a != b:
                    parent[a] = b
    out["components"] = len({find(i) for i in range(len(recs))})

    # Plumb: bottom-slab against top-slab centroid, per post and stake.
    plumb = 0.0
    for rec in parts["post"] + parts["stake"]:
        z0, z1 = rec["aabb"][2], rec["aabb"][5]
        h = z1 - z0
        lo = [p for p in rec["pts"] if z0 + 0.01 < p.z < z0 + 0.25 * h]
        hi = [p for p in rec["pts"] if z1 - 0.30 * h < p.z < z1 - 0.01]
        if not lo or not hi:
            plumb = 99.0
            continue
        cl = sum(lo, Vector()) / len(lo)
        ch = sum(hi, Vector()) / len(hi)
        plumb = max(plumb, math.hypot(ch.x - cl.x, ch.y - cl.y))
    out["plumb"] = plumb

    # The deck: least-squares parabola through the plank top centres.
    tops = [p["top"] for p in parts["plank"] if "top" in p]
    fit_dev, fit_sag, apex = 99.0, 0.0, 0.0
    if len(tops) >= 3:
        s = [0.0] * 5
        r = [0.0] * 3
        for p in tops:
            xp = 1.0
            for k in range(5):
                s[k] += xp
                if k < 3:
                    r[k] += xp * p.z
                xp *= p.x
        m = Matrix(((s[0], s[1], s[2]), (s[1], s[2], s[3]), (s[2], s[3], s[4])))
        c0, c1, c2 = m.inverted() @ Vector(r)
        fit_dev = max(abs(c0 + c1 * p.x + c2 * p.x * p.x - p.z) for p in tops)
        fit_sag = c2 * DECK_HP * DECK_HP
        apex = c0
    out["fit_dev"] = fit_dev
    out["fit_sag"] = fit_sag
    gaps = [(b - a).length for a, b in zip(tops, tops[1:])]
    mean = sum(gaps) / len(gaps) if gaps else 0.0
    out["pitch_mean"] = mean
    out["pitch_dev"] = max((abs(g - mean) for g in gaps), default=99.0)
    mids = [p.z for rope in parts["hand"] for p in rope["pts"] if abs(p.x) < 0.03]
    out["rail_h"] = (sum(mids) / len(mids) - apex) if mids else 0.0
    return out


def add_stray_vert(me):
    bm = bmesh.new()
    try:
        bm.from_mesh(me)
        bm.verts.new((0.0, 0.0, 0.9))
        bm.to_mesh(me)
        me.update()
    finally:
        bm.free()


def make_lod(obj, name, ratio, skip_decimate):
    mesh = obj.data.copy()
    lod = bpy.data.objects.new(name, mesh)
    lod.matrix_world = obj.matrix_world.copy()
    bpy.context.scene.collection.objects.link(lod)
    if not skip_decimate and 0.0 < ratio < 1.0:
        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
        mod.decimate_type = "COLLAPSE"
        mod.ratio = ratio
    return lod


def box_collider(boxes, name):
    """Compound collider: one box per timber member.

    One convex hull over a sagging deck is a lens whose top is the chord
    between the sills, so a walker would float 0.22 m over midspan. A box
    per plank follows the sag; ropes are left out, as thin rope is not
    something a character collides with.
    """
    mesh = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        for centre, ax, ay, az, size in boxes:
            add_box(bm, centre, ax, ay, az, size, 0)
        bm.to_mesh(mesh)
        mesh.update()
    finally:
        bm.free()
    collider = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(collider)
    return collider


def setup_bake_image(obj, target_mat, size):
    if not obj.data.uv_layers:
        return None, None
    img = bpy.data.images.new("BridgeNrm", size, size, alpha=True, float_buffer=False)
    img.colorspace_settings.name = "Non-Color"
    nodes = target_mat.node_tree.nodes
    tex = nodes.new("ShaderNodeTexImage")
    tex.image = img
    nodes.active = tex
    tex.select = True
    obj.active_material_index = TIMBER_IDX
    return img, tex


def bake_normal(high, low):
    scene = bpy.context.scene
    scene.render.engine = "CYCLES"
    scene.cycles.device = "CPU"
    scene.cycles.samples = 1
    scene.cycles.use_denoising = False
    for ob in bpy.context.view_layer.objects:
        ob.select_set(False)
    high.select_set(True)
    low.select_set(True)
    bpy.context.view_layer.objects.active = low
    return bpy.ops.object.bake(
        type="NORMAL",
        use_selected_to_active=True,
        cage_extrusion=CAGE_EXTRUSION,
        use_cage=False,
        normal_space="TANGENT",
        margin=4,
        margin_type="ADJACENT_FACES",
        use_clear=True,
        target="IMAGE_TEXTURES",
    )


def export_unity(path, objects):
    for ob in bpy.context.view_layer.objects:
        ob.select_set(False)
    for ob in objects:
        ob.select_set(True)
    bpy.context.view_layer.objects.active = objects[0]
    bpy.ops.export_scene.gltf(
        filepath=path,
        use_selection=True,
        export_yup=True,
        export_apply=True,
        export_draco_mesh_compression_enable=False,
        export_animations=False,
    )


def check(skip_decimate, lift_z=False, stray_vert=False, low_bake=False, **flags):
    bpy.ops.wm.read_factory_settings(use_empty=True)
    nothing = (None,) * 5
    boxes = []
    low = build_bridge_mesh("BridgeLow", boxes=boxes, **flags)
    high = build_bridge_mesh("BridgeHigh", **flags)
    timber, rope = bridge_materials()
    assign_slots(low, timber, rope)
    assign_slots(high, timber, rope)
    if stray_vert:
        add_stray_vert(low.data)
    if lift_z:
        for v in low.data.vertices:
            v.co.z += LIFT_Z
        low.data.update()
        bpy.context.view_layer.update()

    if low.data is None or len(low.data.polygons) < 6:
        return (fail("bridge mesh did not build", 3),) + nothing

    base_tris = triangle_count(low.data)
    mats = [s for s in low.data.materials if s is not None]
    nmat = len(mats)
    distinct = len({id(s) for s in mats})
    idx_counts = {}
    for poly in low.data.polygons:
        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
    bb = world_bbox(low)
    size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2]

    res = LOW_BAKE_RES if low_bake else BAKE_RES
    img, tex = setup_bake_image(low, timber, res)
    if img is None:
        return (fail("bridge has no UV layer", 3),) + nothing
    bake_result = bake_normal(high, low)
    texels, n_islands = uv_island_texels(low.data, img.size[0])

    lod1 = make_lod(low, "BridgeLOD1", LOD1_TARGET, skip_decimate)
    lod2 = make_lod(low, "BridgeLOD2", LOD2_TARGET, skip_decimate)
    bpy.context.view_layer.update()
    lod1_tris = evaluated_triangle_count(lod1)
    lod2_tris = evaluated_triangle_count(lod2)
    r1 = lod1_tris / base_tris if base_tris else 0.0
    r2 = lod2_tris / base_tris if base_tris else 0.0

    collider = box_collider(boxes, "BridgeCollider")
    col_tris = triangle_count(collider.data)

    export_path = os.path.join(
        tempfile.gettempdir(), f"bdt_rope_bridge_{os.getpid()}.glb"
    )
    if os.path.exists(export_path):
        os.remove(export_path)
    export_unity(export_path, [low, collider])
    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
    # Blender points TMPDIR at its own temp preference, which on a portable
    # build is the working directory, so the export must not outlive this.
    if os.path.isfile(export_path):
        os.remove(export_path)

    hyg = hygiene_audit(low.data)
    zf = zfight_pairs(low.data)
    e90 = right_angle_edges(low.data)
    br = bridge_audit(low.data)

    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
    print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}")
    print(
        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
    )
    print(
        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
        f"overlap={overlap:.6f} nfaces={nfaces} islands={n_islands} "
        f"texels_per_cell={texels:.2f} bake_res={img.size[0]}"
    )
    print(
        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
        f"outer={OUTER_SIZE} zmin={bb[2]:.5f}"
    )
    print(
        f"measured collider_tris={col_tris} bake={bake_result} "
        f"bake_has_data={img.has_data} export_bytes={export_size}"
    )
    print(
        f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
        f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
        f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf} edge90={e90}"
    )
    print(f"measured parts {br['n']}")
    print(
        f"measured joints supports={br['n_support']} support_worst={br['support_worst']:.5f} "
        f"rail_embed={br['rail_embed']:.5f} foot_embed={br['foot_embed']:.5f} "
        f"sill_embed={br['sill_embed']:.5f}"
    )
    print(
        f"measured seats plank_bite=({br['plank_bite_min']:.5f},{br['plank_bite_max']:.5f}) "
        f"lash=({br['lash_min']:.5f},{br['lash_max']:.5f}) components={br['components']}"
    )
    print(
        f"measured deck plumb={br['plumb']:.5f} fit_dev={br['fit_dev']:.5f} "
        f"fit_sag={br['fit_sag']:.5f} pitch_mean={br['pitch_mean']:.5f} "
        f"pitch_dev={br['pitch_dev']:.5f} rail_h={br['rail_h']:.5f}"
    )

    n = br["n"]
    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
        return (fail(
            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4
        ),) + nothing
    if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT:
        return (fail(
            f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5
        ),) + nothing
    if idx_counts.get(TIMBER_IDX, 0) < TIMBER_FACES_MIN:
        return (fail(
            f"timber faces {idx_counts.get(TIMBER_IDX, 0)} < {TIMBER_FACES_MIN}", 5
        ),) + nothing
    if idx_counts.get(ROPE_IDX, 0) < ROPE_FACES_MIN:
        return (fail(
            f"rope faces {idx_counts.get(ROPE_IDX, 0)} < {ROPE_FACES_MIN}", 5
        ),) + nothing
    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
        return (fail(
            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6
        ),) + nothing
    if overlap > UV_OVERLAP_MAX:
        return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing
    if (
        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
    ):
        return (fail(
            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8
        ),) + nothing
    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
        return (fail(
            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
            "(--skip-decimate is the designed fail)", 9
        ),) + nothing
    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
        return (fail(
            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9
        ),) + nothing
    if col_tris > COLLIDER_TRIS_MAX:
        return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing
    if bake_result != {"FINISHED"} or not img.has_data:
        return (fail(
            f"bake failed result={bake_result} has_data={img.has_data}", 12
        ),) + nothing
    if export_size <= 0:
        return (fail("export file missing or empty", 13),) + nothing
    if (
        hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
        or hyg["doubles"] or hyg["ngons"] or zf
    ):
        return (fail(
            f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
            f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
            f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf} "
            "(--stray-vert is the designed fail)", 15
        ),) + nothing
    if abs(bb[2]) > ZMIN_EPS:
        return (fail(
            f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 "
            "(--lift-z is the designed fail)", 16
        ),) + nothing
    if br["n_support"] != SUPPORTS or br["support_worst"] > SUPPORT_Z_MAX:
        return (fail(
            f"supports {br['n_support']} of {SUPPORTS}, worst base z="
            f"{br['support_worst']:.5f} > {SUPPORT_Z_MAX} "
            "(--float-post is the designed fail)", 16
        ),) + nothing
    if (
        n["hand"] != 2 or n["foot"] != 2 or n["sill"] != 2
        or br["rail_embed"] < RAIL_EMBED_MIN
        or br["foot_embed"] < FOOT_EMBED_MIN
        or br["sill_embed"] < SILL_EMBED_MIN
    ):
        return (fail(
            f"joint bites: hand-rope ends {br['rail_embed']:.5f} < {RAIL_EMBED_MIN}, "
            f"foot-rope ends {br['foot_embed']:.5f} < {FOOT_EMBED_MIN} or sill "
            f"tenons {br['sill_embed']:.5f} < {SILL_EMBED_MIN} "
            f"(hand {n['hand']}, foot {n['foot']}, sill {n['sill']}) "
            "(--short-rails is the designed fail)", 17
        ),) + nothing
    if (
        n["plank"] != PLANK_N
        or br["plank_bite_min"] < PLANK_BITE_MIN
        or br["plank_bite_max"] > PLANK_BITE_MAX
    ):
        return (fail(
            f"plank seats: {n['plank']} of {PLANK_N} planks, bite "
            f"({br['plank_bite_min']:.5f}, {br['plank_bite_max']:.5f}) outside "
            f"[{PLANK_BITE_MIN}, {PLANK_BITE_MAX}] "
            "(--float-planks is the designed fail)", 18
        ),) + nothing
    n_lash = 4 * LASH_TURNS + 4
    if (
        n["lash"] != n_lash
        or br["lash_min"] < LASH_BITE_MIN
        or br["lash_max"] > LASH_BITE_MAX
    ):
        return (fail(
            f"lashings: {n['lash']} of {n_lash}, bite ({br['lash_min']:.5f}, "
            f"{br['lash_max']:.5f}) outside [{LASH_BITE_MIN}, {LASH_BITE_MAX}] "
            "(--loose-lashings is the designed fail)", 18
        ),) + nothing
    if br["components"] != 1:
        return (fail(
            f"contact graph has {br['components']} components, need 1 "
            "(--float-suspenders is the designed fail)", 18
        ),) + nothing
    if br["plumb"] > PLUMB_TOL:
        return (fail(
            f"a post or stake is {br['plumb']:.5f} off plumb > {PLUMB_TOL} "
            "(--lean-post is the designed fail)", 19
        ),) + nothing
    if br["fit_dev"] > CURVE_TOL or abs(br["fit_sag"] - DECK_SAG) > SAG_TOL:
        return (fail(
            f"deck off its parabola by {br['fit_dev']:.5f} > {CURVE_TOL} or fitted "
            f"sag {br['fit_sag']:.5f} off {DECK_SAG} by more than {SAG_TOL} "
            "(--vee-deck is the designed fail)", 19
        ),) + nothing
    if br["pitch_dev"] > PITCH_TOL:
        return (fail(
            f"plank pitch off its mean by {br['pitch_dev']:.5f} > {PITCH_TOL} "
            "(--drift-planks is the designed fail)", 19
        ),) + nothing
    if not (RAIL_H_MIN <= br["rail_h"] <= RAIL_H_MAX):
        return (fail(
            f"rail height {br['rail_h']:.5f} not in [{RAIL_H_MIN}, {RAIL_H_MAX}] "
            "(--slack-rails is the designed fail)", 19
        ),) + nothing
    if e90:
        return (fail(
            f"{e90} right-angle edges, need 0 (--sharp-plank is the designed fail)", 20
        ),) + nothing
    if texels < TEXELS_PER_CELL_MIN:
        return (fail(
            f"smallest UV island {texels:.2f} baked texels < {TEXELS_PER_CELL_MIN} "
            "(--low-bake is the designed fail)", 21
        ),) + nothing
    return 0, low, high, timber, tex, collider


def wire_normal(mat, tex):
    """Baked normal map into the timber BSDF."""
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    nrm = nt.nodes.new("ShaderNodeNormalMap")
    nrm.inputs["Strength"].default_value = 1.0
    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])


def render_still(low, timber, tex, path, engine):
    scene = bpy.context.scene
    wire_normal(timber, tex)
    for ob in list(scene.objects):
        if ob.type == "MESH" and ob != low:
            ob.hide_render = True
            ob.hide_viewport = True

    # Level on the floor: turned about Z only.
    low.rotation_euler.z = math.radians(-14.0)

    floor_me = bpy.data.meshes.new("Floor")
    bm = bmesh.new()
    try:
        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0)
        bm.to_mesh(floor_me)
    finally:
        bm.free()
    fmat = bpy.data.materials.new("Floor")
    fmat.use_nodes = True
    fb = fmat.node_tree.nodes["Principled BSDF"]
    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
    fb.inputs["Roughness"].default_value = 0.7
    floor_me.materials.append(fmat)
    floor = bpy.data.objects.new("Floor", floor_me)
    scene.collection.objects.link(floor)
    wall = bpy.data.objects.new("Wall", floor_me.copy())
    wall.location = (0.0, 9.0, 0.0)
    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
    scene.collection.objects.link(wall)

    world = bpy.data.worlds.new("World")
    world.use_nodes = True
    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
        0.02, 0.021, 0.025, 1.0,
    )
    scene.world = world

    def light(name, loc, energy, size, col, rot):
        ld = bpy.data.lights.new(name, "AREA")
        ld.energy = energy
        ld.size = size
        ld.color = col
        ob = bpy.data.objects.new(name, ld)
        ob.location = loc
        ob.rotation_euler = tuple(math.radians(a) for a in rot)
        scene.collection.objects.link(ob)

    light("Key", (-4.2, -5.4, 6.2), 660.0, 5.0, (1.0, 0.95, 0.88), (46, 0, -38))
    light("Fill", (5.4, -4.0, 2.6), 90.0, 9.0, (0.74, 0.84, 1.0), (66, 0, 52))
    light("Rim", (-2.6, 4.2, 3.6), 420.0, 4.0, (0.62, 0.78, 1.0), (-60, 0, 200))
    light("Wedge", (1.6, 4.6, 2.5), 760.0, 6.5, (1.0, 0.70, 0.38), (-94, 0, 194))

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.lens = 50.0
    cam = bpy.data.objects.new("Cam", cam_data)
    cam.location = (2.2, -6.6, 2.5)
    scene.collection.objects.link(cam)
    aim = bpy.data.objects.new("Aim", None)
    aim.location = (0.15, 0.0, 0.56)
    scene.collection.objects.link(aim)
    con = cam.constraints.new("TRACK_TO")
    con.target = aim
    con.track_axis = "TRACK_NEGATIVE_Z"
    con.up_axis = "UP_Y"
    scene.camera = cam

    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
    if engine == "cycles":
        scene.cycles.samples = 32
        scene.cycles.device = "CPU"
    else:
        try:
            scene.eevee.taa_render_samples = 64
        except AttributeError:
            pass
    scene.render.resolution_x = 1280
    scene.render.resolution_y = 720
    scene.render.image_settings.file_format = (
        "WEBP" if path.lower().endswith(".webp") else "PNG"
    )
    if path.lower().endswith(".webp"):
        scene.render.image_settings.quality = 90
    scene.render.filepath = path
    scene.view_settings.view_transform = "Standard"

    fcode = gallery_framing.check_framing(
        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
    )
    if fcode:
        return fcode
    bpy.ops.render.render(write_still=True)
    if not (os.path.exists(path) and os.path.getsize(path) > 0):
        return fail("render produced no file", 14)
    return 0


def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    p = argparse.ArgumentParser()
    p.add_argument("--output", default=None)
    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
    p.add_argument("--skip-decimate", action="store_true")
    p.add_argument("--stray-vert", action="store_true")
    p.add_argument("--lift-z", action="store_true")
    p.add_argument("--float-post", action="store_true")
    p.add_argument("--short-rails", action="store_true")
    p.add_argument("--float-planks", action="store_true")
    p.add_argument("--loose-lashings", action="store_true")
    p.add_argument("--float-suspenders", action="store_true")
    p.add_argument("--lean-post", action="store_true")
    p.add_argument("--vee-deck", action="store_true")
    p.add_argument("--drift-planks", action="store_true")
    p.add_argument("--slack-rails", action="store_true")
    p.add_argument("--sharp-plank", action="store_true")
    p.add_argument("--low-bake", action="store_true")
    args = p.parse_args(argv)

    code, low, _high, timber, tex, _col = check(
        args.skip_decimate,
        lift_z=args.lift_z,
        stray_vert=args.stray_vert,
        low_bake=args.low_bake,
        vee_deck=args.vee_deck,
        float_post=args.float_post,
        short_rails=args.short_rails,
        float_planks=args.float_planks,
        loose_lashings=args.loose_lashings,
        float_suspenders=args.float_suspenders,
        lean_post=args.lean_post,
        drift_planks=args.drift_planks,
        slack_rails=args.slack_rails,
        sharp_plank=args.sharp_plank,
    )
    if code:
        return code
    if args.output:
        rcode = render_still(low, timber, tex, os.path.abspath(args.output), args.engine)
        if rcode:
            return rcode
        print(f"rendered still {args.output}")
    print("rope-bridge OK")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as e:
        traceback.print_exc()
        print(f"FATAL: {e}", file=sys.stderr)
        sys.exit(1)
A wooden rope bridge whose plank deck sags between two pairs of log posts, with hemp hand ropes lashed to the posts, vertical ropes tying rail to deck, and foot ropes run back to ground stakes.