shipping-crate
A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
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.
category Village
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.
| Shipped content | Used for |
|---|---|
skills/mesh-editing-and-bmesh | lathed logs, laid-rope sweeps, chamfered boards, UVs in one bmesh |
skills/procedural-materials-and-shaders | timber grain along each member (GrainDir), per-member tone (PlankTone), hemp with a fibre bump |
skills/bake-high-to-low | Cycles tangent-space normal bake, high onto low |
skills/engine-export-presets | Unity glTF (export_yup=True) |
skills/depsgraph-and-evaluated-data | evaluated triangle counts for the LOD ratios |
snippets/decimate_to_budget.py | LOD1 / LOD2 COLLAPSE chain |
snippets/lod_chain.py | LOD naming and ratio pattern |
examples/mesh-hygiene-audit | hygiene combinatorics (copied, not imported) |
snippets/convex_hull_collider.py is deliberately not used; see Collider below.
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.
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.
| Budget | Band | Measured | ||
|---|---|---|---|---|
| Base triangles | 8300–9200 | 8768 | ||
| LOD1 ratio | 0.32–0.62 | 0.5000 | ||
| LOD2 ratio | 0.10–0.35 | 0.2199 | ||
| Material slots | exactly 2, distinct | 2 | ||
| Timber faces | ≥ 1100 | 1356 | ||
| Rope faces | ≥ 2800 | 3348 | ||
| UV bounds | inside 0..1 | (0.0016, 0.0016)–(0.9984, 0.9984) | ||
| UV AABB overlap | ≤ 1e-5 | 0.000000 | ||
| Baked texels per UV island | ≥ 12 | 18.84 (610 islands, 512 px) | ||
| Outer AABB | 4.619 × 1.073 × 1.480 m ± 0.020 | 4.6185 × 1.0733 × 1.4800 | ||
| Collider triangles | ≤ 400 | 360 | ||
| Normal bake | {'FINISHED'} with image data | {'FINISHED'}, has_data=True | ||
| glTF export | file written, non-empty | ~300 kB | ||
| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0 | ||
| Grounded AABB | \ | zmin\ | ≤ 1e-4 | 0.00000 |
| Named supports | 4 posts + 4 stakes, each zmin ≤ 1e-3 | 8 at 0.00000 | ||
| Hand-rope ends inside their posts | deepest vertex ≥ 0.030 m | 0.05669 | ||
| Foot-rope ends inside their stakes | deepest vertex ≥ 0.020 m | 0.03347 | ||
| Sill tenons inside their posts | deepest vertex ≥ 0.010 m | 0.01980 | ||
| Plank seat on each foot rope | 20 planks, bite 0.002–0.008 m | 0.00400 on all 40 | ||
| Lashing hoop | 12 turns, bite 0.0005–0.005 m | 0.00238–0.00246 | ||
| One connected assembly | 1 component | 1 (60 shells) | ||
| Plumb (posts and stakes) | bottom-to-top slab centroid ≤ 0.004 m | 0.00000 | ||
| Deck parabola | every plank top within 0.006 m; sag 0.22 ± 0.015 m | 0.00297; 0.22037 | ||
| Plank pitch | every gap within 0.003 m of the mean | 0.00008 (mean 0.1689) | ||
| Rail height at midspan | 0.80–1.00 m above the deck | 0.86521 | ||
| Right-angle edges | 0 | 0 |
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.
PLANK_BITE. The bite is therefore exact (4.00 mm on all 40 seats) whatever the lay does under the plank.LASH_BITE. The two turns on each post are rotated half a vertex step against each other. Stacked identically, their outer faces shared planes and the coplanar budget counted 84 pairs.Every convention in showcase/README.md, and whether it applies here.
| Convention | Applies | How |
|---|---|---|
| Deterministic, budgets declared, assertions recompute | yes | seed 31; every value above is read off the mesh |
| Falsifier fails the budget it targets | yes | table below, proven on all three binaries |
| Hygiene incl. cross-shell coplanar | yes | exit 15; KD-tree range query, cross-shell |
| Named supports | yes | 4 posts + 4 stakes (--float-post) |
| Joint-fit / a joint bites | yes | rope ends and sill tenons, deepest vertex by signed distance (--short-rails) |
| Diagonal from stations | yes | suspenders run from the foot-rope centre to the hand-rope centre at a plank gap |
| Even shaping terms / mirror symmetry | no | the 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 profile | yes | lashings take the lathe profile's radius at their height |
| Seat conformance (banded) | yes | plank seats and lashing hoops, both banded (--float-planks, --loose-lashings) |
| Plumb and real-world size | yes | posts and stakes plumb (--lean-post); rail height (--slack-rails) |
| Band hooped, never flush | yes | lashings bite 2.5 mm |
| Member tenoned into its seat | yes | sills end at the post centres |
| Segment counts are a silhouette budget | yes | 16-segment posts, 12-segment stakes, smooth-shaded |
| Material face floors | yes | timber ≥ 1100, rope ≥ 2800 |
| Shading is part of the model | yes | posts, stakes and ropes smooth (round, organic); planks and sills flat (sawn) |
| One substance, one slot | yes | timber (posts, sills, planks, stakes), rope |
| Edge treatment: no right angles | yes | exit 20 (--sharp-plank) |
| Sort bmesh operator inputs | yes | bevel edges sorted by index |
| Variation into surface, never function | yes | plank length, tone and post wobble vary; plank pitch is asserted (--drift-planks) |
| A platform bears on something | yes | every plank seats on both foot ropes, counted |
| One connected assembly | yes | exit 18 (--float-suspenders) |
| Bake texels per UV cell | yes | exit 21 (--low-bake) |
| Bake cage narrower than the nearest neighbour | yes | CAGE_EXTRUSION 0.01 m |
| Rope is laid, not piped | yes | above |
| Identical boards read as CG | yes | PlankTone and GrainDir per shell |
| Level on the stage; stage 60 m | yes | turned about Z only; 60 m floor and wall |
| Keep a falsifier's envelope still | yes | worst deltas: --float-post +12 mm Z, --loose-lashings +13 mm X/Y, against 20 mm |
| Masonry, vessels, scatter, fixtures, roofs, forgings, rings, iron, paint | no | the piece has no stone, vessel, scatter, plate, roof, forging, ring hardware or paint |
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.
| Flag | Target budget | Breaks | Exit |
|---|---|---|---|
--skip-decimate | LOD1 ratio | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 |
--stray-vert | mesh hygiene | adds one loose vertex above the deck, inside the envelope | 15 |
--lift-z | grounded zmin | lifts the whole mesh 50 mm | 16 |
--float-post | named supports | floats one post 12 mm; the others still ground the AABB | 16 |
--short-rails | joint bite | stops each hand rope 20 mm short of its post; deepest end vertex −19.3 mm | 17 |
--float-planks | plank seat | lifts every plank 7 mm off the ropes; bite −3.0 mm | 18 |
--loose-lashings | lashing hoop | sizes every lashing 6.5 mm wider; bite −4.0 mm | 18 |
--float-suspenders | one connected assembly | stops both ends of every suspender 30 mm short; 15 components | 18 |
--lean-post | plumb | leans one post 20 mm at the top, lashings and all; 19.4 mm off plumb | 19 |
--vee-deck | deck parabola | two straight ramps with the same ends and sag; 32.5 mm off the fit | 19 |
--drift-planks | plank pitch | shifts alternate planks ±15 mm along the rope; 31.7 mm off the mean gap | 19 |
--slack-rails | rail height | hangs the hand ropes 0.40 m instead of 0.10 m; rail 0.565 m | 19 |
--sharp-plank | edge treatment | leaves the middle plank unchamfered; 12 right-angle edges | 20 |
--low-bake | baked texels per UV island | bakes at 256 px; 9.42 texels | 21 |
File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Mesh did not build, or has no UV layer |
| 4 | Base triangle count outside band |
| 5 | Material slots, or a material's face floor |
| 6 | UVs outside 0..1 |
| 7 | UV AABB overlap above tolerance |
| 8 | Outer AABB off declared size |
| 9 | LOD1 or LOD2 ratio outside band (--skip-decimate) |
| 10 | Framing gate (examples/gallery_framing.py, render path only) |
| 11 | Collider triangles above ceiling |
| 12 | Normal bake failed or produced no image data |
| 13 | glTF export missing or empty |
| 14 | --output produced no file |
| 15 | Mesh hygiene (--stray-vert) |
| 16 | Grounded zmin, or a named support floating (--lift-z, --float-post) |
| 17 | A rope end or sill tenon not biting its host (--short-rails) |
| 18 | Plank seat, lashing hoop, or the contact graph (--float-planks, --loose-lashings, --float-suspenders) |
| 19 | Plumb, deck parabola, plank pitch or rail height (--lean-post, --vee-deck, --drift-planks, --slack-rails) |
| 20 | Right-angle edges (--sharp-plank) |
| 21 | Baked texels per UV island below floor (--low-bake) |
# 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.
| Value | 4.5.11 | 5.1.2 | 5.2.1 |
|---|---|---|---|
| Base triangles | 8768 | 8768 | 8768 |
| LOD1 tris / ratio | 4384 / 0.5000 | same | same |
| LOD2 tris / ratio | 1928 / 0.2199 | same | same |
| Face counts (timber / rope) | 1356 / 3348 | same | same |
| Outer AABB | 4.6185 × 1.0733 × 1.4800 | same | same |
| Collider tris | 360 | 360 | 360 |
| Deck fit residual / sag | 0.00297 / 0.22037 | same | same |
| glTF bytes | 300212 | 300212 | 300212 |
| 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.
"""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)