From c0c989b55515d45f976cb0d42de7db460d816ffc Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:33:52 -0400 Subject: [PATCH] feat(showcase): add rope-bridge A rope footbridge was the first unclaimed showcase candidate on the roadmap. Its failure mode no bounding box can see is the deck shape: two straight ramps with the same ends and sag pass every envelope check. The piece fits a least-squares parabola to the plank tops read off the mesh, and --vee-deck exits 19 on that fit while its sag stays in band. Also asserts named supports, rope-end and tenon bites, banded plank seats and lashing hoops, one connected assembly, plumb, plank pitch, rail height, edge treatment and baked texels per UV island, each with a falsifier proven on 4.5.11, 5.1.2 and 5.2.1. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> --- .cursor-plugin/plugin.json | 3 +- CLAUDE.md | 2 +- README.md | 9 +- ROADMAP.md | 2 +- docs/gallery/asset-sheets/rope-bridge.webp | Bin 0 -> 37552 bytes docs/gallery/assets/rope-bridge-hero.webp | Bin 0 -> 39416 bytes .../rope-bridge-contact-sheet.webp | Bin 0 -> 61098 bytes docs/gallery/index.html | 15 +- docs/gallery/rope-bridge/index.html | 2208 +++++++++++++++++ showcase/gallery.json | 13 + showcase/rope-bridge/README.md | 241 ++ showcase/rope-bridge/preview.webp | Bin 0 -> 35868 bytes showcase/rope-bridge/rope_bridge.py | 1842 ++++++++++++++ tests/smoke/catalog.json | 3 +- 14 files changed, 4329 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/asset-sheets/rope-bridge.webp create mode 100644 docs/gallery/assets/rope-bridge-hero.webp create mode 100644 docs/gallery/contact-sheets/rope-bridge-contact-sheet.webp create mode 100644 docs/gallery/rope-bridge/index.html create mode 100644 showcase/rope-bridge/README.md create mode 100644 showcase/rope-bridge/preview.webp create mode 100644 showcase/rope-bridge/rope_bridge.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index a75ff55b..8631e252 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -165,6 +165,7 @@ "showcase/wooden-ladder", "showcase/hay-bale", "showcase/crate-stack", - "showcase/stone-archway" + "showcase/stone-archway", + "showcase/rope-bridge" ] } diff --git a/CLAUDE.md b/CLAUDE.md index cf71ef8e..870394c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 28 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 29 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index ba778301..fa932531 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  28 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  29 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 28 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 29 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents reading these files directly from a checkout — **there is no MCP server in this repository, and none is required**. Cursor applies `rules/*.mdc` automatically wherever their scope globs match and takes skills by name in chat; Claude Code reads `skills/` and `rules/` from the project workspace, or from this repo kept as a referenced checkout. Any agent that can read files in a workspace can use it the same way. There is no build step for the content — edit the Markdown and Python files directly. @@ -106,7 +106,7 @@ per-script exit-code model, are in Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).

-28 showcase pieces — click to expand the preview grid +29 showcase pieces — click to expand the preview grid

Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall @@ -137,6 +137,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Hay bale: a bound straw bale with two sisal twine belts on a dark studio floor Crate stack: three slatted shipping crates stacked and yawed, each with iron corner straps and runners, on a dark studio floor Stone archway: a semicircular masonry arch with coursed piers, projecting imposts, nine voussoirs and a proud keystone, on a dark studio floor + Rope bridge: a sagging plank deck on foot ropes between two pairs of log posts, with lashed hemp hand ropes, suspenders, and ropes staked to the ground, on a dark studio floor

[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -191,6 +192,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`hay-bale`](showcase/hay-bale/) — procedural bound hay bale with sisal twine through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`rope-bridge`](showcase/rope-bridge/) — procedural rope footbridge whose plank deck is fitted to a parabola recomputed from the plank tops, with lashed hand ropes and staked foot ropes, through the same pipeline. Falsifiers `--vee-deck` exits 19, `--float-suspenders` exits 18. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index 03a168b3..c0a42876 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,7 +133,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Bound hay bale as a game-prop showcase piece~~ **SHIPPED** as `showcase/hay-bale/` — bevelled loaf, end nap, two sisal twine belts; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Stack of shipping crates as a game-prop showcase piece~~ **SHIPPED** as `showcase/crate-stack/` — one generator called three times with a per-instance seed driving yaw and plank widths, crates seated on the lid below; `--same-seed` exits 20 on the per-instance variation budget, `--float-stack` exits 18 on the seat - ~~Keystone archway with voussoirs as a game-prop showcase piece~~ **SHIPPED** as `showcase/stone-archway/` — coursed piers, projecting imposts, nine voussoirs, proud keystone; the intrados circle fit is recomputed from vertex positions and `--off-circle` exits 19 on it while leaving every other budget passing -- Rope bridge (planks, rope rails, sag between anchors) as a game-prop showcase piece +- ~~Rope bridge (planks, rope rails, sag between anchors) as a game-prop showcase piece~~ **SHIPPED** as `showcase/rope-bridge/` — log posts, through-tenoned sills, twenty planks seated on laid foot ropes, lashed hand ropes, suspenders, staked tie-offs; a least-squares parabola is fitted to the plank tops and `--vee-deck` exits 19 on it with the sag still in band - Grain sacks (cloth sacks, some slumped, tied necks) as a game-prop showcase piece - Brazier (iron bowl on legs, coals, ash) as a game-prop showcase piece - Apothecary shelf (shelf unit, bottles and jars of varied form) as a game-prop showcase piece diff --git a/docs/gallery/asset-sheets/rope-bridge.webp b/docs/gallery/asset-sheets/rope-bridge.webp new file mode 100644 index 0000000000000000000000000000000000000000..dc6c53dbc22f1c7034e6e6c83f811a8153402630 GIT binary patch literal 37552 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/assets/rope-bridge-hero.webp b/docs/gallery/assets/rope-bridge-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..5d49280a30ae2a3635bbac638c5be563e34079ca GIT binary patch literal 39416 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 U0An5_a8FyQwKx8-!YptA02N%9umAu6 literal 0 HcmV?d00001 diff --git a/docs/gallery/contact-sheets/rope-bridge-contact-sheet.webp b/docs/gallery/contact-sheets/rope-bridge-contact-sheet.webp new file mode 100644 index 0000000000000000000000000000000000000000..a5b0c3f2d2a82e73723222bb8070ea175b83da44 GIT binary patch literal 61098 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 and Showcase autocomplete="off" spellcheck="false" aria-label="Search examples and showcase pieces" /> - 51 examples, 28 showcase pieces + 51 examples, 29 showcase pieces

@@ -1210,6 +1210,17 @@

stone-archway

View showcase piece stone-archway
+
+ +
+

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.

+

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.

+ View showcase piece rope-bridge +
+
@@ -1239,7 +1250,7 @@

stone-archway

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 28 showcase pieces'; + var COUNT_LABEL = '51 examples, 29 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/docs/gallery/rope-bridge/index.html b/docs/gallery/rope-bridge/index.html new file mode 100644 index 00000000..6d2abce6 --- /dev/null +++ b/docs/gallery/rope-bridge/index.html @@ -0,0 +1,2208 @@ + + + + + + rope-bridge — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + +
+

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.
+
+
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

+
  • Laid rope. Every rope is a three-lobed section turned one vertex step per ring, so each lobe winds as a strand. The main ropes use 9 vertices; suspenders and lashings use 6. The section frame is fixed to each path's plane: +Y for the ropes that run the span, +X for the suspenders, +Z for the lashings.
  • Rope ends close in a blunt cone. A fan cap maps to overlapping UV triangles. A cone half the radius long maps as one more row of the rope's strip island, so every rope is a single UV island with no overlap.
  • The foot rope wraps the sill with one mitred ring. The bend radius over the arris is smaller than the rope. With a ring every few degrees of arc the section folded through itself and left six right-angle creases, which the edge budget found.
  • Planks are seated from the host. Each plank's underside is the foot rope's own reach along the plank normal at its station, less PLANK_BITE. The bite is therefore exact (4.00 mm on all 40 seats) whatever the lay does under the plank.
  • Lashings are hooped. Each turn's inner radius is the post's (or stake's) own radius at that height, taken from its lathe profile, less 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.
  • Collider. A single convex hull over a sagging deck is a lens whose top is the chord between the sills: a character would walk 0.22 m above the planks at midspan. The collider is a compound, one oriented box per plank, sill, post and stake (30 boxes, 360 triangles). Ropes are left out.
+

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 + 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. + +
+
+ generated from showcase/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/showcase/gallery.json b/showcase/gallery.json index acdbce52..43e2b49e 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -374,6 +374,19 @@ "export" ], "featured_rank": 6 + }, + { + "name": "rope-bridge", + "dir": "showcase/rope-bridge", + "teaches": "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.", + "alt": "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.", + "witnessesFix": "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.", + "hero": "docs/gallery/assets/rope-bridge-hero.webp", + "preview": "showcase/rope-bridge/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/rope-bridge/README.md b/showcase/rope-bridge/README.md new file mode 100644 index 00000000..ef269d36 --- /dev/null +++ b/showcase/rope-bridge/README.md @@ -0,0 +1,241 @@ +# rope-bridge + +![A wooden rope bridge whose plank deck sags between two pairs of log posts, with hemp hand ropes lashed to the posts, suspenders tying rail to deck, and foot ropes staked to the ground](preview.webp) + +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 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. + +## 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. + +| 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. + +## Construction + +- **Laid rope.** Every rope is a three-lobed section turned one vertex step + per ring, so each lobe winds as a strand. The main ropes use 9 vertices; + suspenders and lashings use 6. The section frame is fixed to each path's + plane: +Y for the ropes that run the span, +X for the suspenders, +Z for + the lashings. +- **Rope ends close in a blunt cone.** A fan cap maps to overlapping UV + triangles. A cone half the radius long maps as one more row of the rope's + strip island, so every rope is a single UV island with no overlap. +- **The foot rope wraps the sill with one mitred ring.** The bend radius + over the arris is smaller than the rope. With a ring every few degrees of + arc the section folded through itself and left six right-angle creases, + which the edge budget found. +- **Planks are seated from the host.** Each plank's underside is the foot + rope's own reach along the plank normal at its station, less + `PLANK_BITE`. The bite is therefore exact (4.00 mm on all 40 seats) + whatever the lay does under the plank. +- **Lashings are hooped.** Each turn's inner radius is the post's (or + stake's) own radius at that height, taken from its lathe profile, less + `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. +- **Collider.** A single convex hull over a sagging deck is a lens whose top + is the chord between the sills: a character would walk 0.22 m above the + planks at midspan. The collider is a compound, one oriented box per plank, + sill, post and stake (30 boxes, 360 triangles). Ropes are left out. + +## Conventions walked + +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 | + +## 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. + +| 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 | + +## Exit codes + +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`) | + +## Run it + +```bash +# 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 + +| 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. diff --git a/showcase/rope-bridge/preview.webp b/showcase/rope-bridge/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..443dcfc8918304871d4022b5279dcb571307a055 GIT binary patch literal 35868 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μz0o6ySjQ%hSu;fZ)(RJc4Nc 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 (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) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 678fa48d..bc76136b 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -101,5 +101,6 @@ {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"}, {"name": "hay-bale", "script": "showcase/hay-bale/hay_bale.py"}, {"name": "crate-stack", "script": "showcase/crate-stack/crate_stack.py"}, - {"name": "stone-archway", "script": "showcase/stone-archway/stone_archway.py"} + {"name": "stone-archway", "script": "showcase/stone-archway/stone_archway.py"}, + {"name": "rope-bridge", "script": "showcase/rope-bridge/rope_bridge.py"} ]