From cc22753e5a00969c06afbe97779e98fe5a92ea94 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:34:26 -0400 Subject: [PATCH] feat(showcase): add wooden-yoke The wooden yoke was the first unclaimed showcase candidate. What an envelope check cannot see on a yoke is whether each bow still closes round a neck: a bow bent too tight still stands on the floor, passes up through the beam, takes its pins and fits the bounding box. The piece measures each opening between the bow legs and up to the saddle off the mesh; --pinch-bows exits 19 at 196 mm while every other budget and the envelope still pass. Also asserts the legs stand proud of the beam, the staple bites it, the ring is threaded along and hung on the staple's bar, the pins rest on the beam and the bows are mirrored, 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 | 3 +- docs/gallery/asset-sheets/wooden-yoke.webp | Bin 0 -> 32254 bytes docs/gallery/assets/wooden-yoke-hero.webp | Bin 0 -> 21530 bytes .../wooden-yoke-contact-sheet.webp | Bin 0 -> 54494 bytes docs/gallery/index.html | 15 +- docs/gallery/wooden-yoke/index.html | 1635 +++++++++++++++++ showcase/gallery.json | 13 + showcase/wooden-yoke/README.md | 239 +++ showcase/wooden-yoke/preview.webp | Bin 0 -> 19948 bytes showcase/wooden-yoke/wooden_yoke.py | 1265 +++++++++++++ tests/smoke/catalog.json | 3 +- 14 files changed, 3178 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/asset-sheets/wooden-yoke.webp create mode 100644 docs/gallery/assets/wooden-yoke-hero.webp create mode 100644 docs/gallery/contact-sheets/wooden-yoke-contact-sheet.webp create mode 100644 docs/gallery/wooden-yoke/index.html create mode 100644 showcase/wooden-yoke/README.md create mode 100644 showcase/wooden-yoke/preview.webp create mode 100644 showcase/wooden-yoke/wooden_yoke.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index fa1f9387..7312d921 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -169,6 +169,7 @@ "showcase/rope-bridge", "showcase/grain-sacks", "showcase/brazier", - "showcase/apothecary-shelf" + "showcase/apothecary-shelf", + "showcase/wooden-yoke" ] } diff --git a/CLAUDE.md b/CLAUDE.md index a8a9b3f8..407684a4 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, 32 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 33 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 8750f270..96789be0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  32 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  33 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 32 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 33 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).

-32 showcase pieces — click to expand the preview grid +33 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 @@ -141,6 +141,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Grain sacks: three burlap sacks gathered and tied with twine, two standing and one lying slumped in front, one blue-striped and one red-striped, on a dark studio floor Brazier: an iron fire bowl with a rolled rim on three S-curved forged legs, holding ash and a heap of glowing charcoal, on a dark studio floor Apothecary shelf: a stained wooden shelf unit with an arched crest, gallery rails and three drawers, stocked with labelled glass bottles, flasks and glazed jars, on a dark studio floor + Wooden yoke: a carved oak ox yoke with upturned ends standing on two bent hickory oxbows pinned above the beam, with an iron ring hung under its centre, 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. @@ -203,6 +204,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`apothecary-shelf`](showcase/apothecary-shelf/) — procedural apothecary shelf stocked with fifteen labelled bottles, flasks and jars, whose headroom under the shelf above is recomputed per vessel, through the same pipeline. Falsifiers `--tall-flask` exits 20, `--short-shelves` exits 17. +[`wooden-yoke`](showcase/wooden-yoke/) — procedural double ox yoke on two bent hickory bows with a hung ring, whose neck openings are measured between the bow legs and up to the saddles, through the same pipeline. Falsifiers `--pinch-bows` exits 19, `--edge-on-ring` exits 17. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index 5b236b97..ae73e863 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,7 +137,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Grain sacks (cloth sacks, some slumped, tied necks) as a game-prop showcase piece~~ **SHIPPED** as `showcase/grain-sacks/` — three settled, pleated burlap sacks, one lying, twine ties seated in the neck waist, sacks pressed into one another; flat contact patch summed from the mesh, `--round-bottom` exits 19 on it while zmin still passes - ~~Brazier (iron bowl on legs, coals, ash) as a game-prop showcase piece~~ **SHIPPED** as `showcase/brazier/` — spun bowl with a rolled rim, three one-sweep forged legs, ash bed biting the wall, 36 cleaved coals packed without interpenetration; tip angle from a density-weighted mass centre and the support polygon, `--tuck-legs` exits 19 on it while the feet still ground - ~~Apothecary shelf (shelf unit, bottles and jars of varied form) as a game-prop showcase piece~~ **SHIPPED** as `showcase/apothecary-shelf/` — housed shelves, gallery rails, lapped back boards, a drawer bank and an arched crest, stocked with fifteen lathed vessels in five forms, each corked or lidded and labelled on its own rings; per-vessel headroom under the member above is read off the mesh, `--tall-flask` exits 20 on it while every seat and the envelope still pass -- Wooden yoke (oxbow, iron rings) as a game-prop showcase piece +- ~~Wooden yoke (oxbow, iron rings) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-yoke/` — lofted oak beam with neck saddles and upturned ends, two bent-hickory bows through it and pinned above it, a staple and a threaded hung ring; each neck opening measured between the bow legs and up to the saddle, `--pinch-bows` exits 19 on it while the bows still ground and the envelope is unchanged +- Ox cart tongue or plough beam that hitches to the yoke's ring as a game-prop showcase piece — a hitch is a ring-on-hook contact the yoke's hung ring only tests against a fixed bar - Butter churn (staved tub, lid, dasher) as a game-prop showcase piece - Bookshelf with leaning books (upright and toppled volumes of varied size, one leaning on its neighbour) as a game-prop showcase piece — the lean needs a contact budget the apothecary shelf's upright vessels never exercised: each leaning book rests on its neighbour's spine edge and on the shelf, not in either - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/asset-sheets/wooden-yoke.webp b/docs/gallery/asset-sheets/wooden-yoke.webp new file mode 100644 index 0000000000000000000000000000000000000000..3ef576239b016f7714bc6048ea585ceda83e07b5 GIT binary patch literal 32254 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/assets/wooden-yoke-hero.webp b/docs/gallery/assets/wooden-yoke-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..4ac8a7a7722697da05eed79150bb5aa3c1f5993a GIT binary patch literal 21530 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%0Din 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�hiG(b~ynH3RVh>q3p%#Dh$~? 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 zF|jD!s`NjOW45-_(PMzSOc$U40000000000000000000000000000000000000000 O00000000000002R0V~%4 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index d9df27ad..a7cb210c 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -291,7 +291,7 @@

Examples and Showcase

autocomplete="off" spellcheck="false" aria-label="Search examples and showcase pieces" /> - 51 examples, 32 showcase pieces + 51 examples, 33 showcase pieces
+
+ +
+

wooden-yoke

+

A procedural double ox yoke — a carved oak beam with neck saddles and upturned ends on two bent hickory bows pinned above it, with a staple and a hung iron ring — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 5288 tris, three materials with 1088 oak, 720 hickory and 936 iron faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.424×0.140×0.640 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both bows standing on the floor, every leg 52.7 mm proud of the beam, the staple 17.5 mm into it, four pins resting 1.5 mm into its crown, a ring threaded along the staple's bar and hung 1.0 mm onto it, two mirrored bows, and each neck opening 266 mm wide and 371 mm high. --pinch-bows exits 19 at 196 mm while the bows still ground and the envelope is unchanged; --edge-on-ring exits 17.

+ View showcase piece wooden-yoke +
+
@@ -1283,7 +1294,7 @@

apothecary-shelf

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

wooden-yoke

+

A procedural double ox yoke — a carved oak beam with neck saddles and upturned ends on two bent hickory bows pinned above it, with a staple and a hung iron ring — 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: 5288 tris, three materials with 1088 oak, 720 hickory and 936 iron faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.424×0.140×0.640 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both bows standing on the floor, every leg 52.7 mm proud of the beam, the staple 17.5 mm into it, four pins resting 1.5 mm into its crown, a ring threaded along the staple's bar and hung 1.0 mm onto it, two mirrored bows, and each neck opening 266 mm wide and 371 mm high. --pinch-bows exits 19 at 196 mm while the bows still ground and the envelope is unchanged; --edge-on-ring exits 17.
+
+
blender --background --python showcase/wooden-yoke/wooden_yoke.py --
+ +
+
+

A double ox yoke: a carved oak beam with two neck saddles and upturned ends, two bent-hickory oxbows whose legs pass up through the beam and are pinned above it, and a forged staple under the centre carrying a hung iron ring. It stands on the bottoms of its two bows. 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-bmeshlofted beam, swept bows and staple, lathed pins, torus ring, all in one bmesh
skills/custom-propertiesface attributes (PlankTone, GrainDir) read by the wood shaders
skills/procedural-materials-and-shadersoak and hickory with grain along each piece, rusted rough iron
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/convex_hull_collider.pya hull for the beam and for each bow leg and foot, merged into a compound
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)
+

The budget that matters

+

A yoke exists to close round two necks. Each bow and the saddle above it make an opening an ox's neck passes through. A bow bent too tight fails its job, and nothing else notices:

+
  • it still stands on the floor, so zmin and the named bows pass;
  • its legs still pass up through the beam and stand proud of it;
  • its pins still rest on the beam;
  • it still fits the bounding box, because the beam sets the envelope.
+

The piece measures each opening off the finished mesh:

+
  • Width is the gap between the inner faces of the bow's two legs, taken over the straight run of the legs. Band 240–300 mm; measured 266 mm.
  • Height runs from the bow's inner bottom up to the saddle's apex on the beam's underside. Band 300–400 mm; measured 370.9 mm.
+

--pinch-bows is the falsifier built for exactly this. It brings each bow's legs 35 mm closer to its centre, so the opening closes to 196 mm and the run exits 19. Every other budget still passes, and the AABB is unchanged.

+

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 (only the glTF file size differs, by 4 bytes, which is exporter metadata and not a budget).

+
BudgetBandMeasured
Base triangles4800–58005288
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2197
Material slotsexactly 3, distinct3
Oak / hickory / iron faces≥ 950 / 650 / 7501088 / 720 / 936
UV boundsinside 0..1(0.0100, 0.0100)–(0.9900, 0.9900)
UV AABB overlap≤ 1e-50.000000
Outer AABB1.424 × 0.140 × 0.640 m ± 0.0201.4240 × 0.1400 × 0.6400
Collider triangles≤ 300260 (seven hulls)
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~160 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 bows2 bows, each zmin ≤ 1e-42 at 0.00000
Leg protrusion4 legs, each 25–70 mm above the beam's top at that leg52.7 mm
Staple bite8–30 mm into the beam17.5 mm
Threaded ringring's hole axis against the staple's bar, cos ≥ cos 20°1.0000
Pin seat4 pins, shank 0.5–3.0 mm into the beam's top1.50 mm
Hung ringring's inner edge against the bar's top, −3.0 to +0.5 mm−1.00 mm
Neck openingwidth 240–300 mm, height 300–400 mm266.0 / 370.9 mm
Mirrored bowsextents mirrored through x = 0 within 0.1 mm0.000 mm
+

Real-world size: a 1.42 m beam over two 266 mm openings 0.80 m apart, standing 0.64 m to the tips of its upturned ends. It is a pair yoke for draught oxen.

+

Construction

+
  • Beam. Lofted along X through superellipse sections (exponent 3), 16 vertices each. Every shaping term is even in x, so the two halves are mirror images. Each station reads four functions of x:
  • a crowned top;
  • an underside raised by a cosine saddle over each neck;
  • a sixth-power upturn at both ends;
  • a width that tapers toward the ends.
+

Stations are a uniform run plus one at every leg and bow centre, so the top a pin is measured against, and the saddle apex, are vertices rather than chords. The ends close on a pole a few millimetres past the last ring.

+
  • Bows. A 44 mm round hickory rod swept along a U:
  • Each leg rises straight up through the beam and ends a named height above it (BOW_PROTRUDE).
  • The bottom is a half-ellipse whose lowest centreline point is one radius off the floor.
  • A section vertex points straight down, so each bow stands on a vertex at exactly z = 0.
  • The section frame is fixed to the bow's plane, so there is no twist.
  • Pins. A forged pin crosses each leg tip, its shank resting PIN_SEAT into the beam's crown. Its head hangs past the beam's edge. Two shank rings sit over the crown, so the seat is measured on vertices where the pin rests.
  • Staple and ring. A round bar bent into a U across Y has its legs driven STAPLE_BITE up into the beam. The ring is a torus whose hole axis runs along the staple's bar (Y). Its inner edge rests on the bar's top, RING_BITE into it, so the bar passes through the ring's hole and the ring hangs clear of the floor.
+

Findings the budgets forced

+
  • Pin seat on a span. The pin was first a lathe with rings only at its two ends. No vertex sat over the beam, and the seat audit found nothing to measure. Two shank rings now sit over the crown.
  • A section vertex on the axis. Ten-segment sections have no vertex straight down. That put the pins' seat at 1.21 mm against a designed 1.50 mm, and the staple's bar 0.3 mm off. Pins and staple are now 12-segment.
  • Collider. Hulls over thinned vertex grids still came to 744–766 triangles against a 300 ceiling. The hulls now take every eighth beam ring and every fifth bow ring, with every other section vertex, and the compound is 260 triangles.
  • Bamboo bows (inspection-only). Grain set per face from the path's tangent broke the wood figure at every ring, and the bent bows rendered as bamboo. The bows now take one grain direction, up their legs.
+

Conventions walked

+

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

+
ConventionAppliesHow
Deterministic, budgets declared, assertions recomputeyesno RNG; 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
Named supportsyesthe two bows (--float-bow)
Even shaping terms and mirror symmetryyesevery beam term even in x; mirrored bows asserted (--skew-bow)
Plumb and real-world sizeyesthe neck opening is the real-world size that matters (--pinch-bows)
A joint bites; touching is not joiningyeslegs through the beam (--short-bows), staple into it (--short-staple)
Hung ringyestorus hung on the bar, inner edge on its top (--clip-ring)
A ring is threaded across its holeyeshole axis along the bar (--edge-on-ring)
Carried parts bite their bearersyespins resting into the beam's crown (--float-pins)
Rope, masonry, timber boards, roofs, vessels, scatter, paintnothe piece has none of these
A bent bar is one sweepyeseach bow and the staple is one sweep
Shading is part of the modelyesbeam and bows smooth (carved, bent); every edge over 40° hard (sawn beam ends, pin heads)
One substance, one slotyesoak, hickory, iron
Iron is not chromeyesnear-black and rust, roughness 0.55–0.85, metallic 0.65
Identical boards read as CGyesper-piece PlankTone; grain along the beam and up each bow
Edge treatment: no right anglesn/alofted and swept, no box edges; not a separate budget
Sort bmesh operator inputsn/ano set-fed operator is run
The bake cage is narrower than the nearest neighbouryesCAGE_EXTRUSION 0.01 m
Level on the stage; stage 60 myesturned about Z only; 60 m floor and wall
Keep a falsifier's envelope stillyesevery falsifier leaves the AABB unchanged
+

Falsifiers

+

Each breaks one pipeline stage so a named budget fails. All eleven 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 under the beam15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--float-bownamed bowslifts the left bow 8 mm; the right still grounds the AABB16
--short-bowsleg protrusionstops every leg 10 mm under the beam's top; −2.3 mm at the tip17
--short-staplestaple bitestops the staple 5 mm under the beam; −2.6 mm17
--edge-on-ringthreaded ringturns the ring into the staple's plane; cos 0.000017
--float-pinspin seatlifts every pin 5 mm off the beam; −3.5 mm18
--clip-ringhung ringlifts the ring 6 mm off its bar; +5.0 mm18
--pinch-bowsneck openingbrings each bow's legs 35 mm in; 196 mm wide19
--skew-bowmirrored bowsmoves the right bow 6 mm along the beam; 6.0 mm19
+

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 bow floating (--lift-z, --float-bow)
17Leg protrusion, staple bite or threaded ring (--short-bows, --short-staple, --edge-on-ring)
18Pin seat or hung ring (--float-pins, --clip-ring)
19Neck opening or mirrored bows (--pinch-bows, --skew-bow)
+

Run it

+
# Budget check, no render. ~2.5 s on 4.5, ~4.6 s on 5.2.
+blender --background --python wooden_yoke.py --
+
+# Falsifier: the bows close too tight for a neck. Must exit 19.
+blender --background --python wooden_yoke.py -- --pinch-bows
+
+# Falsifier: the ring turns into the staple's plane. Must exit 17.
+blender --background --python wooden_yoke.py -- --edge-on-ring
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python wooden_yoke.py -- --output wooden_yoke.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 triangles528852885288
LOD1 / LOD2 tris2644 / 1162samesame
Face counts (oak / hickory / iron)1088 / 720 / 936samesame
Outer AABB1.4240 × 0.1400 × 0.6400samesame
Collider tris260260260
Neck opening266.0 × 370.9 mmsamesame
glTF bytes159764159764159760
+
+
+

Source

+
+ showcase/wooden-yoke/wooden_yoke.py + View on GitHub → +
+
"""Game-ready ox yoke — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural double ox yoke: a carved oak
+beam with two neck saddles and upturned ends, two bent-hickory oxbows
+whose legs pass up through the beam and are pinned above it, and a forged
+staple under the centre carrying a hung iron ring. It stands on the
+bottoms of its two bows. Carried through UVs, three materials (oak,
+hickory, iron), a high-to-low normal bake, an LOD chain, a compound
+convex collider, and a Unity glTF export.
+
+The budget that matters here is the one a yoke fails invisibly: each bow
+and its saddle must close round an ox's neck. A bow bent too tight still
+stands on the floor, still passes up through the beam, still takes its
+pins and still fits the bounding box; only the opening knows. The piece
+measures each opening's width between the bow's legs and its height from
+the bow's inner bottom to the saddle, off the finished mesh.
+
+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-bow`` the named bows,
+``--short-bows`` the leg protrusion, ``--short-staple`` the staple bite,
+``--edge-on-ring`` the threaded ring, ``--float-pins`` the pin seat,
+``--clip-ring`` the hung ring, ``--pinch-bows`` the neck opening,
+``--skew-bow`` the mirrored bows.
+
+No randomness. DECIMATE COLLAPSE triangle counts are not byte-identical
+across Blender versions — the LOD gate is a ratio band.
+
+    blender --background --python wooden_yoke.py --
+    blender --background --python wooden_yoke.py -- --pinch-bows
+    blender --background --python wooden_yoke.py -- --output wooden_yoke.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Matrix, Vector
+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
+
+# The beam, lofted along X. Every shaping term is even in x, so the two
+# halves of the yoke are mirror images and the two bows see one beam.
+HALF_L = 0.700
+BOTTOM_Z = 0.360
+TOP_Z = 0.500
+CROWN = 0.050
+UPTURN = 0.140
+UPTURN_POW = 6
+BEAM_W = 0.130
+BEAM_W_TAPER = 0.030
+SECTION_N = 3.0
+BEAM_SEG = 16
+BEAM_STATIONS = 30
+END_REACH = 0.012
+SADDLE_DEPTH = 0.050
+SADDLE_HALF = 0.160
+
+# Bows: a round hickory rod bent into a U, legs straight up through the
+# beam, bottom a half-ellipse whose lowest point stands on the floor.
+BOW_X = 0.400
+BOW_A = 0.155
+PINCH_A = 0.120
+BOW_R = 0.022
+BOW_SEG = 12
+BOW_ARC_Z = 0.200
+BOW_ARC_STEPS = 16
+BOW_PROTRUDE = 0.045
+SHORT_BOW = -0.010
+FLOAT_BOW = 0.008
+SKEW_BOW = 0.006
+
+# Iron: a pin across each leg tip, resting on the beam; a staple under the
+# centre; a ring hung on the staple's bar.
+PIN_R = 0.006
+PIN_L = 0.140
+PIN_SEAT = 0.0015
+FLOAT_PIN = 0.005
+STAPLE_R = 0.007
+STAPLE_C = 0.030
+STAPLE_DROP = 0.035
+STAPLE_BITE = 0.015
+SHORT_STAPLE = -0.005
+RING_R = 0.050
+RING_T = 0.008
+RING_BITE = 0.001
+RING_SEG = 24
+RING_TUBE_SEG = 10
+CLIP_RING = 0.006
+
+BBOX_TOL = 0.020
+OUTER_SIZE = (1.424, 0.140, 0.640)
+
+BASE_TRIS_MIN = 4800
+BASE_TRIS_MAX = 5800
+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 = 3
+FACE_FLOORS = {0: 950, 1: 650, 2: 750}
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 300
+BAKE_RES = 512
+CAGE_EXTRUSION = 0.01
+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
+LIFT_Z = 0.05
+BOW_Z_MAX = 1e-4
+PROTRUDE_MIN = 0.025
+PROTRUDE_MAX = 0.070
+STAPLE_BITE_MIN = 0.008
+STAPLE_BITE_MAX = 0.030
+THREAD_COS_MIN = math.cos(math.radians(20.0))
+PIN_SEAT_MIN = 0.0005
+PIN_SEAT_MAX = 0.0030
+RING_GAP_MIN = -0.003
+RING_GAP_MAX = 0.0005
+NECK_W_MIN = 0.240
+NECK_W_MAX = 0.300
+NECK_H_MIN = 0.300
+NECK_H_MAX = 0.400
+MIRROR_EPS = 1e-4
+
+OAK_IDX = 0
+HICKORY_IDX = 1
+IRON_IDX = 2
+
+
+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()
+
+
+# --- beam shape -------------------------------------------------------------
+
+
+def upturn(x):
+    return UPTURN * (x / HALF_L) ** UPTURN_POW
+
+
+def saddle(x):
+    """Raise of the beam's underside over each neck: a cosine bump, even in x."""
+    d = abs(abs(x) - BOW_X)
+    if d >= SADDLE_HALF:
+        return 0.0
+    return SADDLE_DEPTH * 0.5 * (1.0 + math.cos(math.pi * d / SADDLE_HALF))
+
+
+def beam_top(x):
+    return TOP_Z + CROWN * math.cos(math.pi * x / (2.0 * HALF_L)) ** 2 + upturn(x)
+
+
+def beam_bottom(x):
+    return BOTTOM_Z + saddle(x) + upturn(x)
+
+
+def beam_width(x):
+    return BEAM_W - BEAM_W_TAPER * (x / HALF_L) ** 2
+
+
+def beam_stations(leg_xs, bow_xs):
+    """Stations for x >= 0, mirrored: a uniform run plus one at every leg and neck.
+
+    A station on each leg and bow centre puts a beam ring exactly where a pin rests, so
+    the top the pin is measured against is a vertex, not a chord.
+    """
+    pos = {round(HALF_L * k / BEAM_STATIONS, 6) for k in range(BEAM_STATIONS + 1)}
+    pos |= {round(abs(x), 6) for x in list(leg_xs) + list(bow_xs)}
+    pos = sorted(pos)
+    return sorted({-x for x in pos} | set(pos))
+
+
+def section(x, k):
+    """Superellipse section vertex ``k`` of BEAM_SEG at station ``x``: (y, z)."""
+    phi = 2.0 * math.pi * k / BEAM_SEG
+    c, s = math.cos(phi), math.sin(phi)
+    e = 2.0 / SECTION_N
+    zc = (beam_top(x) + beam_bottom(x)) * 0.5
+    hh = (beam_top(x) - beam_bottom(x)) * 0.5
+    y = beam_width(x) * 0.5 * math.copysign(abs(c) ** e, c)
+    z = zc + hh * math.copysign(abs(s) ** e, s)
+    return y, z
+
+
+# --- construction -----------------------------------------------------------
+
+
+def new_island(ctx):
+    ctx["next"] += 1
+    return ctx["next"]
+
+
+def stamp(ctx, face, island, uvmap, grain):
+    face[ctx["isl"]] = island
+    for loop in face.loops:
+        loop[ctx["uv"]].uv = uvmap[loop.vert]
+    face[ctx["gx"]], face[ctx["gy"]], face[ctx["gz"]] = grain
+
+
+def tube(bm, rings, closed_ends, mat_idx, ctx, grains):
+    """Quads between consecutive rings, fans to the two end poles."""
+    island = new_island(ctx)
+    n = len(rings[0])
+    arc = [0.0]
+    for a, b in zip(rings, rings[1:]):
+        ca = sum((v.co for v in a), Vector()) / n
+        cb = sum((v.co for v in b), Vector()) / n
+        arc.append(arc[-1] + (cb - ca).length)
+    for k in range(len(rings) - 1):
+        a, b = rings[k], rings[k + 1]
+        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
+            stamp(ctx, f, island, {a[i]: (arc[k], i / n), a[j]: (arc[k], (i + 1) / n),
+                                   b[j]: (arc[k + 1], (i + 1) / n), b[i]: (arc[k + 1], i / n)},
+                  grains[k])
+    for pole, ring, s, g, sign in ((closed_ends[0], rings[0], arc[0], grains[0], -1.0),
+                                   (closed_ends[1], rings[-1], arc[-1], grains[-1], 1.0)):
+        for i in range(n):
+            j = (i + 1) % n
+            vs = (pole, ring[i], ring[j]) if sign < 0 else (pole, ring[j], ring[i])
+            f = bm.faces.new(vs)
+            f.material_index = mat_idx
+            stamp(ctx, f, island, {pole: (s + sign * 0.01, (i + 0.5) / n),
+                                   ring[i]: (s, i / n), ring[j]: (s, (i + 1) / n)}, g)
+
+
+def add_beam(bm, ctx, leg_xs, bow_xs):
+    xs = beam_stations(leg_xs, bow_xs)
+    rings = []
+    for x in xs:
+        rings.append([bm.verts.new((x, *section(x, k))) for k in range(BEAM_SEG)])
+    z0 = (beam_top(xs[0]) + beam_bottom(xs[0])) * 0.5
+    z1 = (beam_top(xs[-1]) + beam_bottom(xs[-1])) * 0.5
+    poles = (bm.verts.new((xs[0] - END_REACH, 0.0, z0)), bm.verts.new((xs[-1] + END_REACH, 0.0, z1)))
+    tube(bm, rings, poles, OAK_IDX, ctx, [(1.0, 0.0, 0.0)] * len(rings))
+
+
+def sweep(bm, pts, radius, seg, side, mat_idx, ctx, cone=0.35, grain=None):
+    """Round section swept along ``pts`` with its frame fixed to the path's plane.
+
+    ``side`` is the plane's normal; the section's vertex 3*seg/4 points
+    down the path's ``up`` (t x side) negative, so a path's lowest point
+    puts a vertex exactly ``radius`` below it. Ends close in blunt cones.
+
+    ``grain`` fixes one grain direction for the whole shell. The shader
+    stretches its noise along the face's direction, so a per-ring tangent
+    breaks the figure at every ring: the bent bows rendered as bamboo.
+    """
+    m = len(pts)
+    tans = [(pts[min(i + 1, m - 1)] - pts[max(i - 1, 0)]).normalized() for i in range(m)]
+    rings = []
+    for p, t in zip(pts, tans):
+        s = (side - t * side.dot(t)).normalized()
+        up = t.cross(s)
+        rings.append([bm.verts.new(p + s * (radius * math.cos(2 * math.pi * k / seg))
+                                   + up * (radius * math.sin(2 * math.pi * k / seg)))
+                      for k in range(seg)])
+    poles = (bm.verts.new(pts[0] - tans[0] * (cone * radius)),
+             bm.verts.new(pts[-1] + tans[-1] * (cone * radius)))
+    grains = [grain] * m if grain is not None else [tuple(t) for t in tans]
+    tube(bm, rings, poles, mat_idx, ctx, grains)
+
+
+def bow_path(cx, a, tip_l, tip_r, lift=0.0):
+    """U centreline: left leg down, half-ellipse under the neck, right leg up."""
+    pts = []
+    zc = BOW_ARC_Z + lift
+    b = zc - (BOW_R + lift)
+    for k in range(6):
+        pts.append(Vector((cx - a, 0.0, tip_l + (zc - tip_l) * k / 6)))
+    for k in range(BOW_ARC_STEPS + 1):
+        th = math.pi + math.pi * k / BOW_ARC_STEPS
+        pts.append(Vector((cx + a * math.cos(th), 0.0, zc + b * math.sin(th))))
+    for k in range(1, 7):
+        pts.append(Vector((cx + a, 0.0, zc + (tip_r - zc) * k / 6)))
+    return pts
+
+
+def lathe_y(bm, profile, n, mat_idx, ctx, origin):
+    """Revolve an (r, t) profile about an axis along +Y from ``origin``."""
+    xf = Matrix.Translation(origin) @ Matrix.Rotation(-math.pi / 2.0, 4, "X")
+    rings, poles = [], []
+    for p in profile:
+        if p.x <= 0.0:
+            poles.append(bm.verts.new(xf @ Vector((0.0, 0.0, p.y))))
+        else:
+            rings.append([bm.verts.new(xf @ Vector((p.x * math.cos(2 * math.pi * k / n),
+                                                     p.x * math.sin(2 * math.pi * k / n), p.y)))
+                          for k in range(n)])
+    tube(bm, rings, poles, mat_idx, ctx, [(0.0, 1.0, 0.0)] * len(rings))
+
+
+def pin_profile():
+    """A forged pin: shank with a flat head at the far end, from its near end."""
+    h = PIN_L
+    # Two shank rings over the beam's crown, so the seat is measured on
+    # vertices where the pin rests rather than on a span between its ends.
+    rings = [(PIN_R * 0.7, 0.0), (PIN_R, 0.004), (PIN_R, h * 0.5 - 0.02), (PIN_R, h * 0.5 + 0.02),
+             (PIN_R, h - 0.010), (PIN_R * 1.7, h - 0.008),
+             (PIN_R * 1.7, h - 0.002), (PIN_R * 1.2, h)]
+    return [Vector((0.0, 0.0))] + [Vector(r) for r in rings] + [Vector((0.0, h))]
+
+
+def add_ring(bm, centre, normal_axis, ctx):
+    """Torus of RING_R by RING_T in the plane whose normal is ``normal_axis``."""
+    n = Vector(normal_axis)
+    u = Vector((0.0, 0.0, 1.0)) if abs(n.z) < 0.9 else Vector((1.0, 0.0, 0.0))
+    v = n.cross(u)
+    rings = []
+    for i in range(RING_SEG):
+        a = 2.0 * math.pi * i / RING_SEG
+        radial = u * math.cos(a) + v * math.sin(a)
+        c = centre + radial * RING_R
+        rings.append([bm.verts.new(c + radial * (RING_T * math.cos(2 * math.pi * k / RING_TUBE_SEG))
+                                   + n * (RING_T * math.sin(2 * math.pi * k / RING_TUBE_SEG)))
+                      for k in range(RING_TUBE_SEG)])
+    island = new_island(ctx)
+    for i in range(RING_SEG):
+        a, b = rings[i], rings[(i + 1) % RING_SEG]
+        tang = tuple((b[0].co - a[0].co).normalized())
+        for k in range(RING_TUBE_SEG):
+            j = (k + 1) % RING_TUBE_SEG
+            f = bm.faces.new((a[k], a[j], b[j], b[k]))
+            f.material_index = IRON_IDX
+            stamp(ctx, f, island, {a[k]: (i / RING_SEG, k / RING_TUBE_SEG),
+                                   a[j]: (i / RING_SEG, (k + 1) / RING_TUBE_SEG),
+                                   b[j]: ((i + 1) / RING_SEG, (k + 1) / RING_TUBE_SEG),
+                                   b[k]: ((i + 1) / RING_SEG, k / RING_TUBE_SEG)}, tang)
+
+
+def build_yoke_mesh(
+    name,
+    stray_vert=False,
+    float_bow=False,
+    short_bows=False,
+    short_staple=False,
+    edge_on_ring=False,
+    float_pins=False,
+    clip_ring=False,
+    pinch_bows=False,
+    skew_bow=False,
+):
+    a = PINCH_A if pinch_bows else BOW_A
+    bows = []
+    for side in (-1.0, 1.0):
+        cx = side * BOW_X + (SKEW_BOW if (skew_bow and side > 0) else 0.0)
+        bows.append((cx, side))
+    leg_xs = [cx + d for cx, _s in bows for d in (-a, a)]
+    bm = bmesh.new()
+    try:
+        ctx = {"uv": bm.loops.layers.uv.new("UVMap"),
+               "isl": bm.faces.layers.int.new("UVIsland"),
+               "gx": bm.faces.layers.float.new("gx"),
+               "gy": bm.faces.layers.float.new("gy"),
+               "gz": bm.faces.layers.float.new("gz"), "next": 0}
+        add_beam(bm, ctx, leg_xs, [cx for cx, _s in bows])
+        for cx, side in bows:
+            lift = FLOAT_BOW if (float_bow and side < 0) else 0.0
+            protrude = SHORT_BOW if short_bows else BOW_PROTRUDE
+            tip_l = beam_top(cx - a) + protrude
+            tip_r = beam_top(cx + a) + protrude
+            sweep(bm, bow_path(cx, a, tip_l, tip_r, lift=lift), BOW_R, BOW_SEG,
+                  Vector((0.0, 1.0, 0.0)), HICKORY_IDX, ctx, grain=(0.0, 0.0, 1.0))
+            # Pins across each leg tip, resting on the beam's top at that leg.
+            for lx in (cx - a, cx + a):
+                pz = beam_top(lx) + PIN_R - PIN_SEAT + (FLOAT_PIN if float_pins else 0.0)
+                lathe_y(bm, pin_profile(), 12, IRON_IDX, ctx,
+                        Vector((lx, -PIN_L * 0.5, pz)))
+        # Staple under the centre: a round bar bent into a U across Y, its
+        # legs driven up into the beam.
+        bb = beam_bottom(0.0)
+        top = bb + (SHORT_STAPLE if short_staple else STAPLE_BITE)
+        bar_z = bb - STAPLE_DROP
+        pts = [Vector((0.0, -STAPLE_C, top + (bar_z - top) * k / 4)) for k in range(4)]
+        for k in range(13):
+            th = math.pi + math.pi * k / 12
+            pts.append(Vector((0.0, STAPLE_C * math.cos(th), bar_z + STAPLE_C * math.sin(th))))
+        pts += [Vector((0.0, STAPLE_C, bar_z + (top - bar_z) * k / 4)) for k in range(1, 5)]
+        sweep(bm, pts, STAPLE_R, 12, Vector((1.0, 0.0, 0.0)), IRON_IDX, ctx)
+        # The ring hangs on the staple's bar: its inner edge rests on the
+        # bar's top, and its hole runs along the bar (Y).
+        low = bar_z - STAPLE_C
+        rc = Vector((0.0, 0.0, low + STAPLE_R + RING_T - RING_R - RING_BITE
+                     + (CLIP_RING if clip_ring else 0.0)))
+        add_ring(bm, rc, (1.0, 0.0, 0.0) if edge_on_ring else (0.0, 1.0, 0.0), ctx)
+        if stray_vert:
+            bm.verts.new((0.0, 0.0, 0.1))
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for f in bm.faces:
+            f.smooth = True
+        for e in bm.edges:
+            if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > math.radians(40.0):
+                e.smooth = False
+        pack_uvs(bm, ctx)
+        bm.faces.layers.int.remove(ctx["isl"])
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    paint_pieces(me)
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def pack_uvs(bm, ctx, margin=0.06):
+    """One grid cell per UV island (every face here belongs to a strip island)."""
+    uv, isl = ctx["uv"], ctx["isl"]
+    islands, order = {}, []
+    for face in bm.faces:
+        key = face[isl]
+        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))
+    cw, ch = 1.0 / cols, 1.0 / rows
+    pu, pv = margin * cw * 0.5, margin * ch * 0.5
+    for idx, key in enumerate(order):
+        faces = islands[key]
+        allc = [tuple(loop[uv].uv) for f in faces for loop in f.loops]
+        minx, maxx = min(c[0] for c in allc), max(c[0] for c in allc)
+        miny, maxy = min(c[1] for c in allc), max(c[1] for c in allc)
+        dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8)
+        ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv
+        for face in faces:
+            for loop in face.loops:
+                x, y = loop[uv].uv
+                loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu),
+                               ov + (y - miny) / dy * (ch - 2 * pv))
+
+
+def paint_pieces(me):
+    """``GrainDir`` from the per-face path tangent, ``PlankTone`` per shell."""
+    npoly = len(me.polygons)
+    comps = []
+    for nm in ("gx", "gy", "gz"):
+        vals = [0.0] * npoly
+        me.attributes[nm].data.foreach_get("value", vals)
+        comps.append(vals)
+        me.attributes.remove(me.attributes[nm])
+    grain = [c for i in range(npoly) for c in (comps[0][i], comps[1][i], comps[2][i])]
+    tone = [0.5] * npoly
+    vf = [[] for _ in range(len(me.vertices))]
+    for p in me.polygons:
+        for i in p.vertices:
+            vf[i].append(p.index)
+    for k, g in enumerate(shells(me)):
+        t = 0.5 + 0.3 * (((k * 0.6180339887 + 0.3) % 1.0) - 0.5)
+        for fi in {fi for i in g for fi in vf[i]}:
+            tone[fi] = t
+    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", grain)
+
+
+# --- surface ----------------------------------------------------------------
+
+
+def _sock(sockets, identifier):
+    return next(sk for sk in sockets if sk.identifier == identifier)
+
+
+def wood_material(name, dark, light, rough=(0.72, 0.52)):
+    """Timber whose grain runs along ``GrainDir`` and whose tone varies by piece."""
+    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 = 30.0
+    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 = (*dark, 1.0)
+    ramp.color_ramp.elements[1].position = 0.72
+    ramp.color_ramp.elements[1].color = (*light, 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.0
+    gain.inputs[2].default_value = 0.50
+    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"])
+    rmap = nt.nodes.new("ShaderNodeMapRange")
+    rmap.inputs["To Min"].default_value = rough[0]
+    rmap.inputs["To Max"].default_value = rough[1]
+    nt.links.new(noise.outputs["Fac"], rmap.inputs["Value"])
+    nt.links.new(rmap.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def iron_material(name):
+    """Forged iron: near-black, rough, rusted in patches. Not chrome."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    tc = nt.nodes.new("ShaderNodeTexCoord")
+    noise = nt.nodes.new("ShaderNodeTexNoise")
+    noise.inputs["Scale"].default_value = 40.0
+    noise.inputs["Detail"].default_value = 8.0
+    nt.links.new(tc.outputs["Object"], noise.inputs["Vector"])
+    ramp = nt.nodes.new("ShaderNodeValToRGB")
+    ramp.color_ramp.elements[0].position = 0.45
+    ramp.color_ramp.elements[0].color = (0.035, 0.033, 0.031, 1.0)
+    ramp.color_ramp.elements[1].position = 0.78
+    ramp.color_ramp.elements[1].color = (0.20, 0.085, 0.035, 1.0)
+    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
+    bsdf.inputs["Metallic"].default_value = 0.65
+    rough = nt.nodes.new("ShaderNodeMapRange")
+    rough.inputs["To Min"].default_value = 0.55
+    rough.inputs["To Max"].default_value = 0.85
+    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
+    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def yoke_materials():
+    return (
+        wood_material("YokeOak", (0.075, 0.040, 0.018), (0.25, 0.14, 0.065)),
+        wood_material("YokeHickory", (0.13, 0.075, 0.035), (0.36, 0.22, 0.11), rough=(0.66, 0.46)),
+        iron_material("YokeIron"),
+    )
+
+
+def assign_slots(obj, mats):
+    slots = obj.data.materials
+    for i, mat in enumerate(mats):
+        if i < len(slots):
+            slots[i] = mat
+        else:
+            slots.append(mat)
+
+
+# --- measurement ------------------------------------------------------------
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [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 face_area(me, poly):
+    idxs = poly.vertices
+    v0 = me.vertices[idxs[0]].co
+    area = 0.0
+    for i in range(1, len(idxs) - 1):
+        area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5
+    return area
+
+
+def hygiene_audit(me):
+    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)
+        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 {"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* (copied from showcase/grindstone)."""
+    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 classify(me):
+    mats = {}
+    for p in me.polygons:
+        for i in p.vertices:
+            mats.setdefault(i, p.material_index)
+    out = {"beam": [], "bow": [], "pin": [], "staple": [], "ring": [], "other": []}
+    for g in shells(me):
+        pts = [me.vertices[i].co.copy() for i in g]
+        lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
+        hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
+        rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": hi - lo,
+               "c": sum(pts, Vector()) / len(pts)}
+        m = mats.get(g[0], -1)
+        if m == OAK_IDX:
+            out["beam"].append(rec)
+        elif m == HICKORY_IDX:
+            out["bow"].append(rec)
+        elif m == IRON_IDX:
+            e = rec["ext"]
+            if e.z < 4.0 * PIN_R and e.y > 0.05:
+                out["pin"].append(rec)
+            elif min(e.x, e.y) < 2.5 * RING_T + 1e-3 and max(e.x, e.y) > 0.09:
+                out["ring"].append(rec)
+            else:
+                out["staple"].append(rec)
+        else:
+            out["other"].append(rec)
+    return out
+
+
+def yoke_audit(me):
+    parts = classify(me)
+    out = {k: len(v) for k, v in parts.items()}
+    beam = parts["beam"][0]["pts"] if parts["beam"] else []
+
+    def beam_top_at(x):
+        near = [p.z for p in beam if abs(p.x - x) < 0.002]
+        return max(near) if near else 99.0
+
+    def beam_bottom_at(x):
+        near = [p.z for p in beam if abs(p.x - x) < 0.002]
+        return min(near) if near else -99.0
+
+    bows = sorted(parts["bow"], key=lambda r: r["c"].x)
+    out["bow_z"] = max((r["lo"].z for r in bows), default=99.0)
+
+    # Legs pass up through the beam and stand proud of it.
+    protrude, widths, heights = [], [], []
+    for r in bows:
+        cx = (r["lo"].x + r["hi"].x) * 0.5
+        for sgn in (-1.0, 1.0):
+            leg = [p for p in r["pts"] if (p.x - cx) * sgn > 0 and p.z > BOTTOM_Z]
+            if not leg:
+                continue
+            lx = sum(p.x for p in leg) / len(leg)
+            protrude.append(max(p.z for p in leg) - beam_top_at(lx))
+        # Neck opening: between the legs' inner faces at mid-height, and from
+        # the bow's inner bottom up to the saddle.
+        mid = [p for p in r["pts"] if BOW_ARC_Z + 0.01 < p.z < BOW_ARC_Z + 0.15]
+        left = [p.x for p in mid if p.x < cx]
+        right = [p.x for p in mid if p.x > cx]
+        if left and right:
+            widths.append(min(right) - max(left))
+        under = [p.z for p in r["pts"] if abs(p.x - cx) < 0.002 and p.z < BOW_ARC_Z]
+        if under:
+            heights.append(beam_bottom_at(cx) - max(under))
+    out["protrude"] = (min(protrude, default=-99.0), max(protrude, default=99.0))
+    out["n_legs"] = len(protrude)
+    out["neck_w"] = (min(widths, default=-99.0), max(widths, default=99.0))
+    out["neck_h"] = (min(heights, default=-99.0), max(heights, default=99.0))
+
+    # Mirrored bows: one the image of the other through x = 0.
+    mirror = 99.0
+    if len(bows) == 2:
+        a, b = bows
+        mirror = max(abs(a["lo"].x + b["hi"].x), abs(a["hi"].x + b["lo"].x),
+                     abs(a["lo"].z - b["lo"].z), abs(a["hi"].z - b["hi"].z),
+                     abs(a["lo"].y - b["lo"].y), abs(a["hi"].y - b["hi"].y))
+    out["mirror"] = mirror
+
+    # Pins rest on the beam's top at their leg.
+    # Measured on the shank over the beam; the head hangs past the beam's edge.
+    seats = [beam_top_at(r["c"].x) - min(p.z for p in r["pts"] if abs(p.y) < 0.03)
+             for r in parts["pin"]]
+    out["pin_seat"] = (min(seats, default=-99.0), max(seats, default=99.0))
+
+    # Staple driven into the beam; ring threaded on, and hanging from, its bar.
+    out["staple_bite"] = -99.0
+    out["thread"] = 0.0
+    out["ring_gap"] = 99.0
+    if parts["staple"] and parts["ring"]:
+        st = parts["staple"][0]
+        out["staple_bite"] = st["hi"].z - beam_bottom_at(st["c"].x)
+        r_s = st["ext"].x * 0.5
+        bar = Vector((st["c"].x, (st["lo"].y + st["hi"].y) * 0.5, st["lo"].z + r_s))
+        normal_s = min(range(3), key=lambda k: st["ext"][k])
+        bar_dir = Vector((0.0, 0.0, 1.0)).cross(Vector([1.0 if k == normal_s else 0.0
+                                                        for k in range(3)])).normalized()
+        rg = parts["ring"][0]
+        c = rg["c"]
+        nk = min(range(3), key=lambda k: rg["ext"][k])
+        n = Vector([1.0 if k == nk else 0.0 for k in range(3)])
+        radial = [((p - c) - n * (p - c).dot(n)).length for p in rg["pts"]]
+        r_in, r_out = min(radial), max(radial)
+        d = bar - c
+        in_plane = (d - n * d.dot(n)).length
+        out["thread"] = abs(n.dot(bar_dir))
+        out["ring_gap"] = r_in - (in_plane + r_s)
+        out["ring_tube"] = (r_out - r_in) * 0.5
+    return out
+
+
+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 ring_sample(me, group, seg, ring_step, vert_step, keep=None):
+    """Every ``ring_step``-th ring and ``vert_step``-th vertex of a swept shell.
+
+    Vertices are laid ring after ring in build order, so index order is
+    ring order; the two end poles are the last two indices and are kept.
+    """
+    order = sorted(group)
+    body, poles = order[:-2], order[-2:]
+    nrings = len(body) // seg
+    pts = []
+    for r in range(nrings):
+        if r % ring_step and r != nrings - 1:
+            continue
+        ring = [me.vertices[body[r * seg + k]].co.copy() for k in range(0, seg, vert_step)]
+        if keep is None or keep(sum(ring, Vector()) / len(ring)):
+            pts += ring
+    return pts + [me.vertices[i].co.copy() for i in poles if keep is None
+                  or keep(me.vertices[i].co)]
+
+
+def hull_collider(obj, name):
+    """Compound collider: one hull for the beam, one per bow leg and one per bow foot.
+
+    A single hull over a bow fills the neck opening, which is exactly the
+    space the yoke exists to leave open. Hulls are taken over sampled rings
+    of each swept shell, not over every vertex.
+    """
+    me = obj.data
+    parts = classify(me)
+    groups = []
+    if parts["beam"]:
+        groups.append(ring_sample(me, parts["beam"][0]["g"], BEAM_SEG, 8, 2))
+    for r in parts["bow"]:
+        cx = (r["lo"].x + r["hi"].x) * 0.5
+        g = r["g"]
+        groups.append(ring_sample(me, g, BOW_SEG, 5, 2,
+                                  lambda c, cx=cx: c.x < cx and c.z > BOW_ARC_Z - 0.01))
+        groups.append(ring_sample(me, g, BOW_SEG, 5, 2,
+                                  lambda c, cx=cx: c.x > cx and c.z > BOW_ARC_Z - 0.01))
+        groups.append(ring_sample(me, g, BOW_SEG, 4, 2, lambda c: c.z <= BOW_ARC_Z + 0.01))
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        for pts in groups:
+            if len(pts) < 4:
+                continue
+            tmp = bmesh.new()
+            try:
+                vs = [tmp.verts.new(p) for p in pts]
+                bmesh.ops.convex_hull(tmp, input=vs)
+                remap = {}
+                for f in tmp.faces:
+                    for v in f.verts:
+                        if v not in remap:
+                            remap[v] = bm.verts.new(v.co)
+                    bm.faces.new([remap[v] for v in f.verts])
+            finally:
+                tmp.free()
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    col = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(col)
+    return col
+
+
+def setup_bake_image(obj, target_mat, size):
+    img = bpy.data.images.new("YokeNrm", 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 = OAK_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, **flags):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    nothing = (None,) * 5
+    low = build_yoke_mesh("YokeLow", **flags)
+    hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"}
+    high = build_yoke_mesh("YokeHigh", **hi_flags)
+    mats = yoke_materials()
+    assign_slots(low, mats)
+    assign_slots(high, mats)
+    if lift_z:
+        for v in low.data.vertices:
+            v.co.z += LIFT_Z
+        low.data.update()
+        bpy.context.view_layer.update()
+    if len(low.data.polygons) < 6 or not low.data.uv_layers:
+        return (fail("yoke mesh did not build, or has no UV layer", 3),) + nothing
+
+    base_tris = triangle_count(low.data)
+    slots = [s for s in low.data.materials if s is not None]
+    nmat, distinct = len(slots), len({id(s) for s in slots})
+    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]
+
+    img, tex = setup_bake_image(low, mats[OAK_IDX], BAKE_RES)
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "YokeLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "YokeLOD2", 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 = hull_collider(high, "YokeCollider")
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(tempfile.gettempdir(), f"bdt_yoke_{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)
+    ya = yoke_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}")
+    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}")
+    print(f"measured parts beam={ya['beam']} bows={ya['bow']} pins={ya['pin']} "
+          f"staple={ya['staple']} ring={ya['ring']} other={ya['other']} "
+          f"bow_z={ya['bow_z']:.5f} legs={ya['n_legs']}")
+    print(f"measured joints protrude=({ya['protrude'][0]:.5f},{ya['protrude'][1]:.5f}) "
+          f"staple_bite={ya['staple_bite']:.5f} thread={ya['thread']:.4f}")
+    print(f"measured seats pin=({ya['pin_seat'][0]:.5f},{ya['pin_seat'][1]:.5f}) "
+          f"ring_gap={ya['ring_gap']:.5f}")
+    print(f"measured neck width=({ya['neck_w'][0]:.5f},{ya['neck_w'][1]:.5f}) "
+          f"height=({ya['neck_h'][0]:.5f},{ya['neck_h'][1]:.5f}) mirror={ya['mirror']:.6f}")
+
+    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
+    for idx, floor in FACE_FLOORS.items():
+        if idx_counts.get(idx, 0) < floor:
+            return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 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 {hyg} 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 ya["bow"] != 2 or ya["bow_z"] > BOW_Z_MAX:
+        return (fail(f"bows: {ya['bow']} of 2, worst bow z={ya['bow_z']:.5f} > {BOW_Z_MAX} "
+                     "(--float-bow is the designed fail)", 16),) + nothing
+    if (ya["n_legs"] != 4 or ya["protrude"][0] < PROTRUDE_MIN
+            or ya["protrude"][1] > PROTRUDE_MAX):
+        return (fail(f"{ya['n_legs']} of 4 legs, protrusion above the beam {ya['protrude']} "
+                     f"outside [{PROTRUDE_MIN}, {PROTRUDE_MAX}] (--short-bows is the designed fail)",
+                     17),) + nothing
+    if not (STAPLE_BITE_MIN <= ya["staple_bite"] <= STAPLE_BITE_MAX):
+        return (fail(f"staple bite {ya['staple_bite']:.5f} outside [{STAPLE_BITE_MIN}, "
+                     f"{STAPLE_BITE_MAX}] (--short-staple is the designed fail)", 17),) + nothing
+    if ya["ring"] != 1 or ya["thread"] < THREAD_COS_MIN:
+        return (fail(f"{ya['ring']} ring, hole axis against the bar {ya['thread']:.4f} < "
+                     f"{THREAD_COS_MIN:.4f} (--edge-on-ring is the designed fail)", 17),) + nothing
+    if ya["pin"] != 4 or ya["pin_seat"][0] < PIN_SEAT_MIN or ya["pin_seat"][1] > PIN_SEAT_MAX:
+        return (fail(f"{ya['pin']} of 4 pins, seat {ya['pin_seat']} outside [{PIN_SEAT_MIN}, "
+                     f"{PIN_SEAT_MAX}] (--float-pins is the designed fail)", 18),) + nothing
+    if not (RING_GAP_MIN <= ya["ring_gap"] <= RING_GAP_MAX):
+        return (fail(f"ring hangs {ya['ring_gap']:.5f} off its bar, outside [{RING_GAP_MIN}, "
+                     f"{RING_GAP_MAX}] (--clip-ring is the designed fail)", 18),) + nothing
+    if (not (NECK_W_MIN <= ya["neck_w"][0] and ya["neck_w"][1] <= NECK_W_MAX)
+            or not (NECK_H_MIN <= ya["neck_h"][0] and ya["neck_h"][1] <= NECK_H_MAX)):
+        return (fail(f"neck opening width {ya['neck_w']} (band [{NECK_W_MIN}, {NECK_W_MAX}]), "
+                     f"height {ya['neck_h']} (band [{NECK_H_MIN}, {NECK_H_MAX}]) "
+                     "(--pinch-bows is the designed fail)", 19),) + nothing
+    if ya["mirror"] > MIRROR_EPS:
+        return (fail(f"bows not mirrored: {ya['mirror']:.6f} > {MIRROR_EPS} "
+                     "(--skew-bow is the designed fail)", 19),) + nothing
+    return 0, low, high, mats, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"])
+
+
+def render_still(low, mats, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(mats[OAK_IDX], 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(-22.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, 8.5, 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, kind, loc, energy, size, col, rot=(0, 0, 0)):
+        ld = bpy.data.lights.new(name, kind)
+        ld.energy = energy
+        if kind == "AREA":
+            ld.size = size
+        else:
+            ld.shadow_soft_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", "AREA", (-2.4, -3.2, 3.0), 360.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35))
+    light("Fill", "AREA", (3.2, -2.6, 1.4), 60.0, 5.0, (0.74, 0.84, 1.0), (70, 0, 50))
+    light("Rim", "AREA", (-1.6, 2.6, 2.2), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200))
+    ld = bpy.data.lights.new("Wedge", "SPOT")
+    ld.energy, ld.color = 220.0, (1.0, 0.66, 0.34)
+    ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(50.0), 1.0, 0.3
+    wedge = bpy.data.objects.new("Wedge", ld)
+    wedge.location = (0.5, 1.6, 1.9)
+    wedge.rotation_euler = (Vector((0.2, 0.5, 0.0)) - wedge.location).to_track_quat(
+        "-Z", "Y").to_euler()
+    scene.collection.objects.link(wedge)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (0.95, -2.35, 1.05)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.30)
+    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-bow", action="store_true")
+    p.add_argument("--short-bows", action="store_true")
+    p.add_argument("--short-staple", action="store_true")
+    p.add_argument("--edge-on-ring", action="store_true")
+    p.add_argument("--float-pins", action="store_true")
+    p.add_argument("--clip-ring", action="store_true")
+    p.add_argument("--pinch-bows", action="store_true")
+    p.add_argument("--skew-bow", action="store_true")
+    args = p.parse_args(argv)
+
+    code, low, _high, mats, tex, _col = check(
+        args.skip_decimate,
+        lift_z=args.lift_z,
+        stray_vert=args.stray_vert,
+        float_bow=args.float_bow,
+        short_bows=args.short_bows,
+        short_staple=args.short_staple,
+        edge_on_ring=args.edge_on_ring,
+        float_pins=args.float_pins,
+        clip_ring=args.clip_ring,
+        pinch_bows=args.pinch_bows,
+        skew_bow=args.skew_bow,
+    )
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, mats, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("wooden yoke 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 carved oak ox yoke with upturned ends standing on two bent hickory bows pinned above the beam, with a rusted iron ring hung from a staple under its centre. + +
+
+ generated from showcase/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/showcase/gallery.json b/showcase/gallery.json index cb08cdac..6ebeb97c 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -426,6 +426,19 @@ "mesh", "export" ] + }, + { + "name": "wooden-yoke", + "dir": "showcase/wooden-yoke", + "teaches": "A procedural double ox yoke — a carved oak beam with neck saddles and upturned ends on two bent hickory bows pinned above it, with a staple and a hung iron ring — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "alt": "A carved oak ox yoke with upturned ends standing on two bent hickory bows pinned above the beam, with a rusted iron ring hung from a staple under its centre.", + "witnessesFix": "Recomputed: 5288 tris, three materials with 1088 oak, 720 hickory and 936 iron faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.424×0.140×0.640 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both bows standing on the floor, every leg 52.7 mm proud of the beam, the staple 17.5 mm into it, four pins resting 1.5 mm into its crown, a ring threaded along the staple's bar and hung 1.0 mm onto it, two mirrored bows, and each neck opening 266 mm wide and 371 mm high. --pinch-bows exits 19 at 196 mm while the bows still ground and the envelope is unchanged; --edge-on-ring exits 17.", + "hero": "docs/gallery/assets/wooden-yoke-hero.webp", + "preview": "showcase/wooden-yoke/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/wooden-yoke/README.md b/showcase/wooden-yoke/README.md new file mode 100644 index 00000000..f784f86f --- /dev/null +++ b/showcase/wooden-yoke/README.md @@ -0,0 +1,239 @@ +# wooden-yoke + +![A carved oak ox yoke with upturned ends standing on two bent hickory oxbows pinned above the beam, with an iron ring hung from a staple under its centre](preview.webp) + +A double ox yoke: a carved oak beam with two neck saddles and upturned +ends, two bent-hickory oxbows whose legs pass up through the beam and are +pinned above it, and a forged staple under the centre carrying a hung iron +ring. It stands on the bottoms of its two bows. **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` | lofted beam, swept bows and staple, lathed pins, torus ring, all in one `bmesh` | +| `skills/custom-properties` | face attributes (`PlankTone`, `GrainDir`) read by the wood shaders | +| `skills/procedural-materials-and-shaders` | oak and hickory with grain along each piece, rusted rough iron | +| `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/convex_hull_collider.py` | a hull for the beam and for each bow leg and foot, merged into a compound | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | + +## The budget that matters + +A yoke exists to close round two necks. Each bow and the saddle above it +make an opening an ox's neck passes through. A bow bent too tight fails +its job, and nothing else notices: + +- it still stands on the floor, so zmin and the named bows pass; +- its legs still pass up through the beam and stand proud of it; +- its pins still rest on the beam; +- it still fits the bounding box, because the beam sets the envelope. + +The piece measures each opening off the finished mesh: + +- **Width** is the gap between the inner faces of the bow's two legs, + taken over the straight run of the legs. Band 240–300 mm; measured + 266 mm. +- **Height** runs from the bow's inner bottom up to the saddle's apex on + the beam's underside. Band 300–400 mm; measured 370.9 mm. + +`--pinch-bows` is the falsifier built for exactly this. It brings each +bow's legs 35 mm closer to its centre, so the opening closes to 196 mm +and the run exits 19. Every other budget still passes, and the AABB is +unchanged. + +## 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 (only the glTF file size differs, by 4 bytes, which is +exporter metadata and not a budget). + +| Budget | Band | Measured | +| --- | --- | --- | +| Base triangles | 4800–5800 | 5288 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2197 | +| Material slots | exactly 3, distinct | 3 | +| Oak / hickory / iron faces | ≥ 950 / 650 / 750 | 1088 / 720 / 936 | +| UV bounds | inside 0..1 | (0.0100, 0.0100)–(0.9900, 0.9900) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 1.424 × 0.140 × 0.640 m ± 0.020 | 1.4240 × 0.1400 × 0.6400 | +| Collider triangles | ≤ 300 | 260 (seven hulls) | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~160 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 bows | 2 bows, each zmin ≤ 1e-4 | 2 at 0.00000 | +| Leg protrusion | 4 legs, each 25–70 mm above the beam's top at that leg | 52.7 mm | +| Staple bite | 8–30 mm into the beam | 17.5 mm | +| Threaded ring | ring's hole axis against the staple's bar, cos ≥ cos 20° | 1.0000 | +| Pin seat | 4 pins, shank 0.5–3.0 mm into the beam's top | 1.50 mm | +| Hung ring | ring's inner edge against the bar's top, −3.0 to +0.5 mm | −1.00 mm | +| Neck opening | width 240–300 mm, height 300–400 mm | 266.0 / 370.9 mm | +| Mirrored bows | extents mirrored through x = 0 within 0.1 mm | 0.000 mm | + +Real-world size: a 1.42 m beam over two 266 mm openings 0.80 m apart, +standing 0.64 m to the tips of its upturned ends. It is a pair yoke for +draught oxen. + +## Construction + +- **Beam.** Lofted along X through superellipse sections (exponent 3), 16 + vertices each. Every shaping term is even in x, so the two halves are + mirror images. Each station reads four functions of x: + - a crowned top; + - an underside raised by a cosine saddle over each neck; + - a sixth-power upturn at both ends; + - a width that tapers toward the ends. + + Stations are a uniform run plus one at every leg and bow centre, so the + top a pin is measured against, and the saddle apex, are vertices rather + than chords. The ends close on a pole a few millimetres past the last + ring. +- **Bows.** A 44 mm round hickory rod swept along a U: + - Each leg rises straight up through the beam and ends a named height + above it (`BOW_PROTRUDE`). + - The bottom is a half-ellipse whose lowest centreline point is one + radius off the floor. + - A section vertex points straight down, so each bow stands on a vertex + at exactly z = 0. + - The section frame is fixed to the bow's plane, so there is no twist. +- **Pins.** A forged pin crosses each leg tip, its shank resting + `PIN_SEAT` into the beam's crown. Its head hangs past the beam's edge. + Two shank rings sit over the crown, so the seat is measured on vertices + where the pin rests. +- **Staple and ring.** A round bar bent into a U across Y has its legs + driven `STAPLE_BITE` up into the beam. The ring is a torus whose hole + axis runs along the staple's bar (Y). Its inner edge rests on the bar's + top, `RING_BITE` into it, so the bar passes through the ring's hole and + the ring hangs clear of the floor. + +## Findings the budgets forced + +- **Pin seat on a span.** The pin was first a lathe with rings only at its + two ends. No vertex sat over the beam, and the seat audit found nothing + to measure. Two shank rings now sit over the crown. +- **A section vertex on the axis.** Ten-segment sections have no vertex + straight down. That put the pins' seat at 1.21 mm against a designed + 1.50 mm, and the staple's bar 0.3 mm off. Pins and staple are now + 12-segment. +- **Collider.** Hulls over thinned vertex grids still came to 744–766 + triangles against a 300 ceiling. The hulls now take every eighth beam + ring and every fifth bow ring, with every other section vertex, and the + compound is 260 triangles. +- **Bamboo bows (inspection-only).** Grain set per face from the path's + tangent broke the wood figure at every ring, and the bent bows rendered + as bamboo. The bows now take one grain direction, up their legs. + +## Conventions walked + +Every convention in `showcase/README.md`, and whether it applies here. + +| Convention | Applies | How | +| --- | --- | --- | +| Deterministic, budgets declared, assertions recompute | yes | no RNG; 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 | +| Named supports | yes | the two bows (`--float-bow`) | +| Even shaping terms and mirror symmetry | yes | every beam term even in x; mirrored bows asserted (`--skew-bow`) | +| Plumb and real-world size | yes | the neck opening is the real-world size that matters (`--pinch-bows`) | +| A joint bites; touching is not joining | yes | legs through the beam (`--short-bows`), staple into it (`--short-staple`) | +| Hung ring | yes | torus hung on the bar, inner edge on its top (`--clip-ring`) | +| A ring is threaded across its hole | yes | hole axis along the bar (`--edge-on-ring`) | +| Carried parts bite their bearers | yes | pins resting into the beam's crown (`--float-pins`) | +| Rope, masonry, timber boards, roofs, vessels, scatter, paint | no | the piece has none of these | +| A bent bar is one sweep | yes | each bow and the staple is one sweep | +| Shading is part of the model | yes | beam and bows smooth (carved, bent); every edge over 40° hard (sawn beam ends, pin heads) | +| One substance, one slot | yes | oak, hickory, iron | +| Iron is not chrome | yes | near-black and rust, roughness 0.55–0.85, metallic 0.65 | +| Identical boards read as CG | yes | per-piece `PlankTone`; grain along the beam and up each bow | +| Edge treatment: no right angles | n/a | lofted and swept, no box edges; not a separate budget | +| Sort bmesh operator inputs | n/a | no set-fed operator is run | +| The bake cage is narrower than the nearest neighbour | yes | `CAGE_EXTRUSION` 0.01 m | +| Level on the stage; stage 60 m | yes | turned about Z only; 60 m floor and wall | +| Keep a falsifier's envelope still | yes | every falsifier leaves the AABB unchanged | + +## Falsifiers + +Each breaks one pipeline stage so a **named** budget fails. All eleven +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 under the beam | 15 | +| `--lift-z` | grounded zmin | lifts the whole mesh 50 mm | 16 | +| `--float-bow` | named bows | lifts the left bow 8 mm; the right still grounds the AABB | 16 | +| `--short-bows` | leg protrusion | stops every leg 10 mm under the beam's top; −2.3 mm at the tip | 17 | +| `--short-staple` | staple bite | stops the staple 5 mm under the beam; −2.6 mm | 17 | +| `--edge-on-ring` | threaded ring | turns the ring into the staple's plane; cos 0.0000 | 17 | +| `--float-pins` | pin seat | lifts every pin 5 mm off the beam; −3.5 mm | 18 | +| `--clip-ring` | hung ring | lifts the ring 6 mm off its bar; +5.0 mm | 18 | +| `--pinch-bows` | neck opening | brings each bow's legs 35 mm in; 196 mm wide | 19 | +| `--skew-bow` | mirrored bows | moves the right bow 6 mm along the beam; 6.0 mm | 19 | + +## 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 bow floating (`--lift-z`, `--float-bow`) | +| 17 | Leg protrusion, staple bite or threaded ring (`--short-bows`, `--short-staple`, `--edge-on-ring`) | +| 18 | Pin seat or hung ring (`--float-pins`, `--clip-ring`) | +| 19 | Neck opening or mirrored bows (`--pinch-bows`, `--skew-bow`) | + +## Run it + +```bash +# Budget check, no render. ~2.5 s on 4.5, ~4.6 s on 5.2. +blender --background --python wooden_yoke.py -- + +# Falsifier: the bows close too tight for a neck. Must exit 19. +blender --background --python wooden_yoke.py -- --pinch-bows + +# Falsifier: the ring turns into the staple's plane. Must exit 17. +blender --background --python wooden_yoke.py -- --edge-on-ring + +# Render the gallery still (EEVEE; --engine cycles on a GPU-less host). +blender --background --python wooden_yoke.py -- --output wooden_yoke.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 | 5288 | 5288 | 5288 | +| LOD1 / LOD2 tris | 2644 / 1162 | same | same | +| Face counts (oak / hickory / iron) | 1088 / 720 / 936 | same | same | +| Outer AABB | 1.4240 × 0.1400 × 0.6400 | same | same | +| Collider tris | 260 | 260 | 260 | +| Neck opening | 266.0 × 370.9 mm | same | same | +| glTF bytes | 159764 | 159764 | 159760 | diff --git a/showcase/wooden-yoke/preview.webp b/showcase/wooden-yoke/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..9137d273e15f1901c5e398a6604b10b1eb2e66bb GIT binary patch literal 19948 zcmV(&K;geqNk&HGO#lE_MM6+kP&gpiO#lEeiUOShDzF5j0zNSqibNtKAta)cxS)^* z32AQs-DeLWXO=_vNdBB1=D(ZrJsR#lQG4UXz7gER>h98&7Jc7&d;dq(=6DH{-~%1= zb=^s3{+ikG_22fq&3bqAkFuXh->3iN|A_tn`Um}F{de`-_EY<*>NEPU`ET35cJKTD zx!&==&3c1>fd6&Y?fx_WH?arp@298k7sOBQ=kE6x|4RRS{TY16{XZD>n`K|!0P}F3@7pa-o;()aeqAjRqrwv4eGkl1ZXxVHEmsuml$(fkuV6S>TdU&^Qx+c# zncBD|Nf6)2|1-y*MpZ#!;a-E|qsO)C3rxIv18o{%l9~r$QMbQirUgSS$24iucfb`O zR<5Cynnic-u}O8EK?hujHAw(Z=zXU5>}`#^GNBz3lTl5`Jn@ZT`S!dQAON?o%c7Lj zcFq|kKm-}9YkQ#&Kksrm7uoibNt3i`mwu(qr1Utw2_cX3?WW;z3>MDA8Ke1)EKIMc z(&JH7ZG`l>1$Eh|(tBgf`JQUM77W;kdDrFA!fHGq&0mUI0@@$KItpudsA)Nqm=lJ7 z22w^X$e_I%jM)4^^Ir74{9}Yn28@7T*nvUq{^jd{Ha}8ywA6S5CNi6wWVU_|u9mkC z3X11cKhn3FA?!Z+Qhk!j$vUKtWfjj8EN=X`(v(kMhKIbmJG(uF;&`OocuEPgHSuSl zA#_tg*FGr-p0rtl8gZ=53O_1lvgQg*dddIdSohCNX&ANU$#z-lu=OgS2c%&kdfeZv|x;zP<{~BhbDsmhxC@oj3`AGFGnHcCsQ{VV&@sEJsxKU5F zp}4Ir5~G`gWFAm3NncDPRlXV^es(`oGyS3WjkKaCkkjGza8`;us{Cl;_0%l@C4vzS zewu%|4d45yw!*}TzSo8M2_t9MaPWgQeObz0_2#N!pl{1nW-kPEr^Y?*mHLRr3`tDe z=|~_PD`FWN!K~-7nvOx}>jK<&n%xRB#$s@o^@xYaOb<&dpG_w*%l{P+4IecXc+fZg zpz?Ie>T>@)26AxpAyTq!Os7R*%tc*`YmCN=q}Qs!EW|v!0q2&qlPr}fvo++Lwa_T} z*cj|jYZFl2HPf7Ppy$Xt^eOU9Ua;FHZ@i6{(64UJBtq7fn7NKbt z86z{mjN>b$88K&hUt)V)@1=Uu!S}V;5KSxA%nXpCLB)pjL|S6Z2uXw)=9PCuIwf`V zpfK%8H`3>i892Q&310(FsDbNu%s&^46}R>UOy^xlV^wa64Ja5CtzK8pnHX`IpN`7l z+I!x0OK>&{_l!{ogZdjJOBe;A)2hS$YEcDt1-ic%`0SMUGxj%_ zfh4dVT?z*^LRF`}*AaGUs0yGg|A_eT`9ZLBJOzk0j1dY;9qzmD_;G10kd;-NCi^c= zi#Oz20-pnM2lYR^NB$w8NzyUmVXv`2N+Iskpo>A?ZS{!~rM{Z5(oVIKWL}UZcq|m6 zN|>!g`SW6Nh*l*7>cSN6Om48>(eI~xp%c}XZJ|(|cuG#@ri6 zOhzzU0Ov`FxRfq&r>UIOVYb&pEBryd$bY70a|kAk*h@3>unHgz6mIyAXCaU(n;VHD z`~wInd1BAM6ytcAgj;BDO~@oAcGCdE00`+Vc-zd>n)|9A zMg8jrdH4AJX9LuuJ2GPM@PGN;kiEOSh{L>JSfyqs&ne__H$SNs>MDg6()RkKUl{0+t#6i6wA2HR@(U8T<#boNIR;alDzND zeq9Xgxp%G#I2g2=`@jc@c{{?2fP+1A_QqO>zJIGsWATx6gG#Es@JO49%7G!!rie?! zET_OMs_5OvITm(HD5;}hTK3;Fw2)D{?%GG4>J;++iKLn!5iBa_LN0u zQ~1_lH)IYRC9fR#EjR8)&4wu_HHq`@XLu z#ZlC1tbQ0HOakj}(I$)qok?A$)Y27%bW}|i7pEpb<<(c1`M~BwtHCNpc@?i>A=_4+;J*Op_f z7cdGvhU==KC~0zRoH8F%R<9zYV6qSA#F2Xy~!$!jqv>Zcc$3! zuTzK!B3{hJpZ~rq|M6Baagf~+=y|(L=xJ+mSy(zs(tu?FMt=Zje^~!VUf~;??XAAY z;m%ICL2&LI08i3qcFE`O8{U&|;dA1gz&$Fo3ZO_o z9h25|;(a-6a_vI`VB(LaCW(PFEKK~b-oAgMnHV^F0e{xf84V<({!Chqi>Bi6#G~kRAu2z#kwjFWVB}M6`LQRj3Q<-^6PB2c z!h3%3IXC|cAz;UmQTIHhrDJsrKvjv&Q`s37VV=Ht7{MZc$4E)DV5W_su0I~ccbNOT zsXkpPV-GjaZM`2=^}RY1RyXr)1?9 zxY}tb+x9oSOj(z~Cf5}HGgaP`y7JQAIL^g{8J_GKXsaa7j#{;<=<`iE@K_g9np)oO z7SU@Iu%ld_qR){FeX@%u%+}$Si7AGjDv)u!m&ea5EA!}NL&EKGig{(FrNC}lqr7Zt zV^4E}4K07OFXh%w6ICrCEbtrR7}{QtNbHSZrxQZzFHRiGSvl?}c7aBZ3m3_)a3%>^ zFM)nj6IcYEPf^G%1CDh80!Uv7_~=@ZkT$c08+eFapChSJ-LX&}>31^(pe$0<=0u_& zB;rc}MQs*#CAyK=n<@>-5^45tPi%S_81od8VBNVvktZB}G;iF}6f$5xA@R5i*A50Y z4Y%mz8^i3whx&eF7BT=w=9Aj^Khx6oAR8=7MKqb-BR3#iF0>I^u`s`h5`^Ts*0}2b z`r$=eVRsTeSb&1#9S;+V)*29GhNT4B*|z8`2nM*$a6Fc=jq&doJEM(xx;GGM+NpumleA8Qa0g@1h_T(58vB`B$hkB8J6r= zFYTIoljC-D4+Qt@;;EmWN@feXiK*+#Nz8qy%h=t9iI}-7pjNYZjjf--0DTkms0qRj zDk8-Ik)vb5YFa)BVJ@)8%z2s2q)ao1z0%NM?=WQJ54(He3(MlKF-zCQqvl~hgd9b; zK9~`wCB|EhE?!1?{4EQ!Ytf0hii{l3mEayc=jK(Pu&b^8PPCSX88z4+VhRb{JP9tG zem77J^^(H}Qlu6-rO=nt4&%fnFq0FWxPr7pKn8 zc)WAN3-9=GilHk!ef6eIGrwW)lqI=GIRE2r^h+eLb^J4VvP4V}y02&1Z26jsKgf_E z{-^vRNs%N?Wc)QN$4SWgSPzrft%{2(A-hb;-eE- z?c&8J67Df4T)bM&+ExBGD|l?P7tTq4-@!1t{CO=#7173HS`P_YG)^j%R#H#*lQ@8y zFg#%8m#dib_jW>l?DE#d>O;9tu=F2C1V-!o54hT51T_Bx`vqIu*t&aw-g%*fiW^d1 zxaRv3W~)CIV7!J8#y^R(2*ukUf1;?^B)m+ViYCCb&5!ZnT3-vx3h7KdUN3ZymX8}V zu_;QUv)P=fHKz*oarpj?uy%}FQUSL{l4|-?%&=fJi@D~fh_qGIlMJ+{3S;_-Y@u^ zgPawoclmOFj(Q4*J*Sj=x4}-zBiY<5K}&WX!l1!+vDa4-*=2S?0C>h<#DA9D>^s>R zGe|3sGp*D+K31T#4RGgFdoGaof5?JPA%j=6#6Q9fE@K+fPU_wT)056pkO!4+tN_Jp z=QT#hdw&S&svt|}Tz zLgkmjV7B31zlcNT%9INMwT(Z=(ULlVOQ}2;nTp*NP)E*npUX-b)p!z$x_nXF_@(<( zK7e%RHI!~Nb7VV#kwBc6CwwI~lW81bCce1epXl9%ZVG;tct3-zX=CtQ6J|}d;rMh+kS=Gt^i|asm zP>L)Ih;~m3gGg{1PM8z(BfwJr!DxR`i(W40@aV}jmwD-}%f}cJoEJ${jv+lci`!(1 zH$mtPKgqK#|BCRIX^W-ziz-$lOCBlhpVmeKu(i&LdtPtO82`%zi(FeE&#*sjkt>0m z2=ri&X&Je>_p#*`f0fF)vZmc>!05d zGyn1(V9a33#Co8S-`G<&fasDmn%>>gwm&RAIS8r8c{f2_$A_4e^b~@N^2g;sl!ac0 zW3mn__V^#+lpqM>26Ps>CDm2ii@W(81xKVBM`=g=nez0LT$u*fw%2|9#VvKvu`#yd^|c;E4USRp_)Q_v}v%a%r1G5mi&b2=PhHeJQMGsqJ%@ZMt^T2VFUC`AL9hqT-a zg1KG->2H%m`~UO{p7>uVq*Fm$mS+2jbyEP2aQg(nd_Dt;OGTIQL#4Y$l#r)SSq7&< zY#xV&5`!s(C;CI&>%R&}Y1%42!6}9)V3<f!&bS` z>+KH@Gt0iez@Jq`a=4<2Q8g)RRpmnLsdoOTWO&4;NTwLw^)uvn!i;q9+I(Xzj~+8N zX`Is)P*O_%)k)qCW)37a+y2u|b|kT&bal_3mg3(PUhG3U$pym!K~k}LxOfnU`i+%3 ze3N92-v_H8EGfMbMbje7}&3+=b>$kRY(57myFdG zun=^$UA8RhhgPWgB=N6Y(TDcZuALni{+P6|x=}MY`yhto%N6*5(7{4xAkv4!HY6`2 zifn5kBrO;(gBIijJ7Fg~MzyJ}aqvDXH3teWKLzNzIj`6pi`fkg#aQv|0TuWdIHX5147(efsr%=Dadj^m`0=6nOrK{`J^fST9_thg zc*qHD;V}jq@9n&SdKaVTPyyn``qrb%(0bw7Za$sO9Spc0l2-oKR(O|%!$@+Q&VPjQv%uBNk zRqZ(NZCP*H%5-=JpQQNx1?hC?%nRfDtZ8KyC5Joln|=B=gwP8FHX3%82I0 z+@UVes5RoK7C3Dk3VRYDt9(%|mCV5?G~omLarfn$+@qK%Ng5A}>ej>45&_m;p}my$ zB5B7?9))JdQh;PaF6!^apt!X4mnTQhosaYEKveG}&ta+=?#m|7XClcFh_GyvXhagr zBKMqs?-Lr5YM?mt_&{0d=vjIHX7g7!ig9)(Iv$DcdN7GPU6ZcYyrs#svy9=jbI7WV z_M<(3^|mUa)AN9EMm;n8qPGAupq6^R5K5HLbq4(Ce{Me?pSsf9Y}NQtgFm<7Vv2|< zlQ#P?gM!tM-DC7nj~D^k!zIPMu$wNI$}2V0NjWAUZP&$?iF2gxZMG_xzv`cXz?fB4stGDY1;G4*w;|#R-#D9TEGX zBi|$6Bi|$6Bi|$6Bi|$63P<_>x@ke2jUDZY9>%fAA6F=v8o5y>45j8w+p7JV8O?%T zQmmmLDM-jw9j_L@sc@x`mhq%ILhBiE7r3)py*nus_b51qjtTEovJsQmi%-r?8Mhmr zo&Smpo>{Ucz3&@KPL+?Dj$m}aN9q)KBJ*`|#;VJ~v!jiB95T+dOntJdQO|aU8To5K zO(FtC=kE!Ka{vg&=PB!Y8Pr*?@DWiuxe>S@A+vEJnm{?a370a-*;5DV&O6ZLM29WeJ3?%QoWyEjjD0tYylz!A{LoFcC)3R`XE`8-IDajfWmXDqH-L z{B-**>Y&7*j1Y14auP}r&kuFR%QR$~P9g9T>C*#b3F-WWxd)Y$-?zI`Zryis_)iCR zq^)Ti6``bH(|x#KL5mK`u?cvAnjHvar!j%sc-H_R;&N8{j+ppf;-j9lXs5y0fleEC zMkCaHt3GnNe=t!fh9A%Km&Wx2!;h%Xf)yJ6?yvUlNiCjGm}De;(POYZ17|iH2Q%Oq zMy|BJybcN*Na{7$$4ni0kD1X5ouih0^oY=_ z)ka?#Y0(t!z6L5zV|7)O<%8}l798<*Y#sz7q8*4zn06K)T&!wSAjWF7>_RN7TCYJL zo>uJ@gTaopwcs?Csz+Qt;Rf{!wI~~Vee{uTDn4ZIJJ$5-j4-_!`1bkJGf7L*c+9VE zXf&xL(jQG-5j$N;7w0Q9>d8g(n)med#n<-Ro_}ya(Vhcje@#7n4*{Y;A*Z|YZR{TU zDbrcrMX*D|Jd5XR4}0kLM3o0LIoW7enf3QMZHuQZlSeaY%TGn)m z)2hTGc0`zc9Qf2bNCvx|J?w+uFDrRt77UV3Zp&Rxdnj2dd{s<_CX#>5CXupo>Y8fi;Yc8#P9jE?ksRQNbfhhYEU?vTC7nqj*Tl~BdmR%JFqgRJy-AB44n zCr`Ap}FH=1lnOF~d8W?~x z+4e_kwri@6%{-pyuS4(J<%P)j0sv0$!RvT9<h0_` zvpb%>ab%qv*3cqK!3;+N7G*4L`PGU3(m?~`-!TgCc}w0XsU^EDQ1_sA`@rxCJSCx7`DwX?c3YU5;E_~w~3l<(AjaX);D`{elgP=(d#U&^MM~_T!pp=vXiPC zjMRYW_X*FW+=X2zm`or}ULCA~G*WB+*pMV@5c`j@-t)TB{43`}^3nwT*DlQL5ik6^ z+UQr*J04a2?CQkQ>riBNqt52&f3;Q4_q_!J>cUVonU3h}E`dr`8!4{ktB_4w#rkI( zfj0Loa4*dYIMbJlBVwY` z55GBfBsB6cm6`!<8x4h?b0mj-H(W_q8!2U`ry9R&=*`jUQsr0C6*llsx5u>j2j?cF zZ9rz>PKLuD7Mu>Y6)F`nLc6mNMA{iD-eugO^>MWyh>?V)5)8kNbXyBMr-U#2i#v5^ z@rAj>`zJ@V3F-EyMM;y8?m9TC3_4&_nNM6b1RD3^FIdwGnwT1P%+d$_TEy{wJqS%`b$~aI zB5%gox!RJ~wLy!nChrBE$;CDn$5A7$xbH=s5PfavB3e>O!13=gG(F}P0V1Zl*e)ZH z(a3|T3hC#<<=t&%%Bd=23;dEW_m=EvRWHEsL5zDpAeD0CP(Zf!%VfKpts>m1W#NQnV{-5m)sf&coSv%|{3qd4rjYZ4vOF7!<9*U=I8fBeiQZn3IJa znK4d4;!kl zFhdx5w6Ht)IrKZ2yxQ&#DdXc6@#Z#J(4plr>%8HCi*>`dR(7O{*z<;}CDPE4G$qh2 z!Y5<>l+28*QC4ZbDhG$N`SyKp^$=?U(5+^REdzHz6p!&e{5$rBQU;pj7j&B=)nNfs zUvHYvO==OlV2wuMv-14kLhEG@o>weq+yWBd$Xu$bw$cqqg9%|9wxV7IaWH!lkzV{< z3O=+w)N!p#Soob`6eLAN=zUUmJ@ho`)>Be=y#Ndab^vum=$dE&FEJM9EToZ>>^R=9 zRb+y%S^$pG1Qj$Hsx*(gCKNpRk2*4eSow8>N2J*Cyol)S>aTVm0FMvGgyV-SH@>l+ zL&~R`Q)LGbwy`l!`KO^FBpCIfdphw03SYDd1kaT@aTWDtpnPDVX8KeijRRyS?+;wq zER)hJaXpR`v7Em0?^(`ksU`)*(@l^%>5?`N1}5qF5MF);6nbbsmP6y$`j~I~ec3^1 z$~am$_GV&i02lv^?yNZiIjRk&)o5XQI*y(`0mnO1-fpk=Q(rfJgMENqiKW9DhrhL6 zbBn|Y5_40DldTtJF_9@D4)OQvgI!-pYwi~tP zJ6rdSx?YRABq#=3M59DBP=RP>caYv@jP$3XPr+xFkzE_96J-JqA;B5q*i`m?6(AlU z&Y1!Z;IBmJJ4FAw^-3GBfHc_48Baf?37QD4ISuvCer{>M zmXRz9iVPx<6W~|renOU5k3y39JeunZ)m0>Q`5`?SE8C7vn|Q7$0n_`yAh}l?jm0&y4~(=Qvwg%KdRC#HjUE zj{ZlcHe_A+w<@Iy<7{rD=GWm~S$EO?7WvYw|5yp*59k~{yM;PFjMtzi+kXI4gB2?= z!j$Jg2TDT5># zdrD~Z9xt;{?(Gb7V(oVQPLj}<(_OZ5Y-e3;Z@sAEC;3gVb(5TG=HP{ z7?68yTR{4pxHJvQbYHB4E^V?h{*BPB(OLZKtBIF-TNqMTlxxYRoprnOcmC-l?4joA z{Yxe7z?l5xc6me9~lS zvRIBN0t35VRh~4 zHCP){a_U0rNbl=*YaY|?blFmKLaz(`W4PPe_=?gSBVJ(=-u14im$CQlk4QHYGZ`l% z;U^nZmieDIDsA)U!i)*k?3_GJ7*E|5cg(kSj^Xofk2_00bG@`H6fIYKA^81(wAos1 z1xo-Xyw6kR#19%uyku;XqGDUUtyQKUHtu6H;Vy*hUxvX(3?!gAw7^dk(MN@`68R0Q z>a#zO^SyzLS&K6h6>9ae1!WdTd1{JlERh8l9y;mlP+#+3B5}R6#Uc8(_5W{I+R*zH zI~lM)*Q>|gvl;Z111vROI1&(MIUcRkPI|FnzR4>nL$hyxOEP$5!$PUsyffBL;jw1B zOu(C;$*aLvr4f|(rNAdQUmLEkn*ow|>SmOAB)2n#vs%Q#;)VT2=dWfNsIt;){&aHL zkr8HnR{)7cj4AlH*ATg@t)mDDSeek8g)mTl=kS_FXbP4y4K`e0jBhU!_9Ydw*S(H^ z{5kpN>Z0WQAmfN0(2EBn%c?#oFiO+n*3HkT0KrpOi5d1D-QlE^v);e)4Ji=>Jv2l% zR*|%n6$}|v~_eQq-z3Rw%v7<>+z)o}`4LKrL%`8z>bj<{az< z4n*P(qKw?StM5a$WQ^&WfNATcl^AA>&pq{vB8{P@+ljQa=Xm&aP?Cs1TE*NucchU& z(iKYNH~jYS-Nr)2dvczFn4QGwaqS>*M+Ycxb|;dERth+6FaApFa&)ja9DLjsOSSpc zUE-*$=6Xu$r37#*i%Tj&5forCPID$AH?8^zt%)&Xc;d*QZd|dE8|;fH)#>NErmt4P z&)_NH0jGWw434ZdpbHiLiR}7F=O;Obw8BeVgR3VPOTN%>pU}j{E+~7aqaquX%g&ex zX;W_c$X=g4L3XuapQpZTe%5!K$@!TLT%#>p{4U$}TC)p0@h1+kW(C$D_-ISI+%h2s z*lTekOU9NE>XQ9Ls1!VCt4m}r8W~g&TCMO9gT?%p50Iq)8?PGpWMK+zY8FUrmWEY&GBm*mS@%e?3LXp^h zL{o%!S!r7LG}iuRxsQmmsazX3_notB^`3a#CHG4)HRaM&UiMbq;TJVYbI4UAd(M0J ztVm9YQ>Qmy$+oK|u4_|=TlNGo%y7_tI`dnf@k1#5Gmq6uYuKKAlif`6hVJ%hKC6oL z$3ak@AZ7?E5qG@IciE=T-uUyamr&~#7OKMl2qpU_PcM;kbaEbn72Wx=*)Z6HrtVp1 zGeN+NGgvxt_x$)aF?&d>FnLVaWTl)b6M$1zV}Z)^n;>$;kBBy`HCiJGmG&>+_P<*Z5^=YM($1tPmXTWm+l6DLid44YqK0VOC4(K>4p|#Oyl~u?_T8#0tP>cMZ zxR_lh^@>-)@f=syM51y^t8$O3(F3gRYor~dRPbNHD5Qqo_%rltSR{+-O1G4#e-uI+ z&~~=4#$?6Ty0~8`;W28CXkMx8LuoiQwZ>M%b3E;D>z|OE3gJ*!Q@!C<6}_XT1V8@- zXa`X}yEaoQ)lQkFZvyrVI#!fCB<)jne{e%^W>|i^zCq5rwF)vczfA-h8a&`uzy}XQ z=}f3)S3cqSB>BX3wZGck6?;z#w{Tn5Ol{yinPd=%ei%5Bp)9hJUv}Iy?N@{@jZ+r0 z8%Q6^ujiFL$Fh7&M82*2eRha*>I?)UnmVa9#$bniOw$rkM`Y7jjd_63ayMwzNBVf; zdvoBCe78ZN(#z;2w}|MrQhIRJ`3*5Ui({Xc?(-x!mRUoqLYG62pT=17LFU>ukH126 zCk#{>_Hd|GFu&sE9l5fctZH!I6A?y_s{bI5IWm?epYl&6piWT^YZw65-kz4WPyvzhF z?I?y0K&7+*s zFDYG3Q2rQENY7tyY{momuQCY4SHe!5ow>}Rp-5oHh$WBKsuo~!j1?aYvXMETHrm4S zq9i}`QQrU9RudS=Ms<-)pp={Gx=DL-QHOimF_N9iOAt5Ivp*Vi!fYPpKfAfiYL zU3N{5hgy5v1g{F6MBHa2fYn;tNz})??%%OWsqvFqx$`$1h$bNhcAieEle>1eE56%o z#wnE2IM2ENX0;R&61+(k21MEsl{c|Xou>&n^65^5WJ|P|W5ZrDL;#|h<)pxGQye~U;LCWr(K#T zcJ%>0j%&}ronpmaebNwpjI<*xX#ze`tD|YM-Ce6H5!LAZ(Aa(=)Szl8 zVH#H^mPo~a6;%n9cwbL%t2WW2q!K)urN*wtTbRiM!cD%>al&}z@=hynYpJSiL6w^H zXHAkOOt9a@%DR7tC)grcI5%Z@YyFvNsPwSD1~R2e+vUWlC@|c-&cF0KgI8#wQ|_y= zZEAoN)5FjB@1-=72uYJ0mYO(^pTsSdUaIM`M{w zaSRLu!IXlyi8zwXQKdrEIMu*%nJZEQ-zi2<*N%Arb_`#5e7Z0idS;wNf!(%H=>*A3 zU^KT9z?+0g6RbASjb9?x$7Dr2$a^?R?^EurTI(Qs|9 z9ki0XK2}#dT06LdKzi}X?;kmr@zGGVvS_pNvo53gbI4@RQM-oz3ktJ7^9h#>OojnliT)_B_+U`*4y!Ed*ZP zxFnYF@aVejKF*!-N+Q5bxoAP|H6LlSe_yC}9aNAsb2M+C! zYswpp=?8P|I*u~I=B@_ir`#7--Roo9$2!tr$5Bd@>L+v^QIT5cMy_w}*y~pFL~5c< zt#r!BHN#GK_viYXnojN=2b%4wU$~7vd?`XSF%}c-VOFGrVcr% zEn*zViXmV0PWctZn?H1~{`~AYV`j0w-TCEWrSKPDP?5ssbCuCejQuT>Har{4%+bxD z{FKu;8PWGs2^wTmM$^4zu~SRJ5-{fb)iVB&125h6Jdg9`PcLo;YPTbo(8&=!Hkxh~ z+e%l%Lu-NXoos_GP3k53lsL&rB6i**g!!8?|MASx9sK{AADKCCG3NtoXG(!pY@h$xY$-17fJ*yFr)TZxYBfOumeG%_TB>26^cT$`bxde=J<$-v#>2eTaf=Lz>_6~I*O1|Xz{La>5y@+vU+A`FNIj{e@F}>swW%IHD;?nze z-1tnk;m3?mX&U{E$V*7GM=oS@?r{FC11&4N6$Hq~3U~Rm=$x*=$4&n+_QGEW^~>gq)tDs*LP(X-0^7596v14k9M9;U zBDp=3^URU{ZnuKOm1g2JX)__?I`-KkrXzVBoVR|SCE&+!r4RYvaj zaD7kd#S{RA@O8ldhetiawil68t5Bj9XViDD^8X4aeSs^uW8F0*^_d4>)}2Hfs!$D>2X-?6!y)`2WMZH)|W zWqS@JARC<%sJ4 zuj-q|WJs!l@BE)U&>Xy<5;lUPv|OU8*!a0}%GuaEy)1wf(^srQH0%3uk=M>3_nSt&mJx6ZlEKThM-z(m z8#1UFfX^uC71@8B(v}TuOb0)O2BsN)ICuR0nap~q+*#EdT1qw-bt<$ z0ccezHO;f*q=oiKTO9>ZB~{|$WaO7yDm+|);tOO3tkpM$r9Rb%vnGimf;~6xy5#G- zyVtLw6t!?v6na7cMT~sw`qJyV><=)~zyg1ax!(m{Jfmt*73NBAdOM8%neRt+aG?#l ztH6d}y?{D@Z@R9H2xgUPF4o@SS=Gk)p);CM-7cBsNrvJa`A5yJA?`*t>XdK1m(Oh| z8bVF8GwU&QEHtt@S4+t4EgapnpCo=eI7f7pxQ6hygU`+Z=2&Z;5Vw0v>mBOXXzzbv z%r+jNG%xG2d?|s@&S?TU=@sq7d}x#MeI?37Q8mmYIEE^{)Om{eI)Vew!sNrQn};64 zDPVdN`L^^Zr`B4iOxUVm;g?J>8lYp3_F!dy{_3BUWz}&Sq>D+TZ!5@be!r{RUD9dM zEhvn^XBlL+MKy$t*i6T{4A6~O4Y)T$MpvAZ06-RnLGwW(ZK}N||HSsxyPKt9%;O~f zyoL)V4WJOT{^HgnpZJV8r29FtnB`EIa(BIx%=<5T|F(BwZ5e1ocb$PPv9!UtC$eFk z^ZM1+r?^ZD<&KyFLqP^Z{%n8>SA4f)kb5%^*=8&bs-?tp2tE+NWCbK_O4F)Q4#vr& z0bGZ~yJy|Xbi?K)CVY+bmE+|`>oood^$WuGx??D*y2pDOb%!SUgv;O-Bue2?deN>x zPj}(P1Vl+&g90EmxL><)9CQZQg20zRq5V%FW17WAN^q}sCcuI@bK&BL`1dggG9OnP z#&dcvG8__1x<|>WcYmgBdajbZ?vX;{e`>5D(cVJyutegM2D}PgH8veAmd%+wJP zmj8}6UXhhgQx-R~*{13-a|ta;?7in0RK=g^TRKnT&k#N{YY+n9iTc9L|D;i6M*7=^ zR;p>Vl=gxzZ)M7L!Y@!X$*DE?xvzLYe9eO8;VBqeK&3H@EcgiyZl4rZ`^1#sksfjQ z_Ih_JK^8h5N&XpejiTu6Y&V;%OhIMr$vfJfY&z^{$@KieNYvZK@IusWSdZC5U*)sR zI#2=Ja{gJfB~BuHn>&Dp@RPIt)6i)`KWGm~qio8g6bgSKYh73a<80F|_xbfUzCSw7 zBK}7=@!gY>v?-5HVjx?9f0w~+r>SLdKl5|Qr~=nTPO?L4HX8nan3ppRt)xk}MM!js zose$7Vy-1NHRfiXFT8H1A&-N~3JJbu0afV%BCXl5^tGyA6J=LT@FliFIDfV5-L;7I z#XC?Ns`Fgnkec;<+j(nG68rYWgBzYdih{Tp%4 z=*d-93l1xe66Hck4K7E^7V1BnO>w>0O~d|*|E(hZ_r`S@R9yhySH*)&4&d}mb7(?- zC;J`Ze$wG@QwVI|1bhMD5DPYmxQi@RL3N2q8@|)BE|)KoRVzN!0-zjUybKJvXvevY z<9h93RL-+c6heczJl{oTVQQM~RlvxhrgRz+(d9fMhYvo;9j><*8VP%hy}$;s z^udfFP`QlCdpGoeD;;bA1VnSUiDeOl<0Qb*THAP8^z#zJYLd3;1XnEQ%E?_!k5Xoy zm)8KCftVR$I1t6;z!zRr`n5%!-1l+8TYl-+mNZ9+M>(eE-hAZ(0|#XHgYwEn=~N@Bh(qyB+O)H=$^p&|G4eLuxf;!M zcnUfJ12VkBfQSLoB(gJ91Mn{zUdItu!kZ(_S#%GUPI!SLT7J~%NDNdxj09%FY9}Ry`Wa7Sp6$h>6MXN_sczce`5UDvFIYP7eKd0gK zaT{qLqsgnY);tc7;qHmJr>}&T$RwqFpq~+zm5D1`FR#BB$k6EGhfvxXl^ITh$SEG> z82K}{?*ZQn+I%eLryRF{(pVt@NF8(2WJqIe`&yH$W05vqwoJ1 zjzeicjaFV|l7MF?HGB`ur=j(YWn?fmkDCefHmY-rSwQ;S3fJ4>J4QkE&)4%& z&PdMeH3iy9y?RevOs$Z0j1G1lw)aaIWzi06R$&D|=&4ksn=Y{04q58dUQwFE%J3ri z^`ekC!e?63X7}g~?C0_49$zZim7Tkpx{BZ|+!~y@q-^3i8YW!?4rZ_j7Lm4k^&An` zC3W=VQcIF)Q!cEhJV~S-fGR?105`HHQ%;sVCEDK<6OhzpRxw&E8Y<{t5KLX)won`V z9!-Z3S)Fo_{d6PU2({>v9BaHaD!)Z_?WnI&9Q{z_SuBd-z59dc7AY(7__aQbI;ePi25>LUF)MZVw_k8`;as z|5(zNb;V3I^NYBWC{M65b!)P@8xy?0B%1? z!%xvUJOu~TrTUC*j#VD%bc~&Ltl2L$Z=#sGQK*CnsN%s}hHpx0qhU4Z1Tol14Tmp( zV(}MU#8}Y5R5a{l_It|}*R^EwKJ|GRkvj&7i=v8Nj^*&e zv9;yu0Gn6)iUxp*)WzN_K8cP9RiAqo+jy{P#klR&3IG6^#}XwSYhSh+te>y{aUbc5 zBxNm~u$)Qzt(et2j^-Z+MZ0uyG$70v6?V7)I@)1a=hdjBc=h3X$-{=Hlpm$|6o(DE z?lnXG8;-3XxQUViD3q(Dc26BV1#7IbXb$r4apd|SeH>dR^M>ruX*{*0Lq-yMM!-o4 zjsR9T3$N@O8UI00x}730nSU5zG-RK2CxA2dHcX-e&f*jaow{rmtkr#v<*r0G6h)c3 z@oRD?(^maBwsJ40-W<&Z>?yh5>i;a5;U|tkcyRadWW-HJb8!NWN@h6Z{1&zVRdT2J z@LLD=5U2nEB{pFgtGY!Q=u&hrW=5Qyr>@(myY0tBF}W7jih}sAHHSS?MGZ5H2X1yv zaPa1aXOS-Ir4s`0To>bKzsXLH#ZA&f0wE=APq6ys;k#x22b)<3{}$H)ljfV);$h}K zgZ|+Jdz@>>3u^5lY)51q#{7zfcilIcn6`bj1FDm&b~8)K*JD;o)>AuVRfVnZ4sY*s z&VHuj)h0Lqy_ed$sS*qT&W9`ve#unU*0Rn^{yiFz8$Cg>f~?alEZC%b0TW3t zdeS8?^}7|$p839tH`rckBW)rjRlpM%B6A^0Hjnz31Ar2`0000_v221^#M=CS#tRG= zV!G9YEWLx|$N)Y@F|vOen+-a*+)*BAPv9AO0bX&%@k26@iT6qsC)Pg%@pyvyaqp5+ z2-)0sc;7DyT0j0<>H5>M@m!T>C~U={oh0;kVCG@7Uf~O%Wqh%V;+Jf3>w{CK4+Jx$ zl)7aU**E=@h2#Sr^9_IJq|eWCUx3DMN1h ziz|@~0D09i+v1%o;=;9|=a$VijjTumfqPWa*zCVssIfo*G6(gWY5=$t2QNp7zTR9N z9$q}g7Q^9ekzX>B*IGX+n>ltWfP`m207AF}_Ifpt1;$=O%>@Xu^K44!W4FL3n>AMq z7uyPvGaZu5^?~UW6i}og=FIHX*I0N4%{`V?8C~)^^bMCqtp1Sqd zfGL~Mj$6S55@9U7`gxRbsm+_MeQeOhs!qfRMlV#sgIpgRL3VkXKG1k(Zj7(?z+lN_ zwjX$DWHx7O)ub0Xh@3sv5UEnAQPyE`+Q$d$$9nj7ivW}{QWR#A!Tzj$PRbUFDnDT} zjI#xz`ImNZOI0e)HanJs>m3V)@M(QE(0_d7C4x;EJjl)#sC~JfYefnsxd!X+F^Wy) z2nPrOqyQjpoNqfITP`3|_ef}o*ak5pMmQ5izjEK*FTb|K%sA7Qg;`36l;9T@0}5u9 z9S<^VkX$0rckuhP=eAQAIcqyH0E;jCdw&{}Rl5IwQk}CSuWn*wY?q!I);1zHeHv&s z&*_ms8$vPnaubsz#WaBpnE6N1s|Ioc(Tn2Z3Alb`T7nO;8bofmJno$E=iTR|$DLem z?F))iw|fU87i?&G6}j*!+ewAT0mOKLE9C$az1a}wx0UYacL?>gsG3`1UbGZH9zg>~ zDh!p=TKg9DK5x~V24y*t14I>-81)7?q}G~G6pj01Fd*E}*&#mFIP!|~fS&#K1)=7u z@8wkPiu5XT9$aAMEb5bHQdg>NB8;I~O2uy@YheNkKcF+V9{Ae3X9 zakcDvn)?Tk+4}lAST#U9fB??`1hWyZNhTW`M_S``KuyMd7%MbFMSL0CEDy*smE?m` zeG*gZUfXf()c+f=I*-;6>>jTs>kY z>0Zy2%6#gz=}BF@E}woZc(+UZ3xEl3eBgq{;p}%j59~`n8+|Gk)Rp z!gu2sLm*_OQrs*UQJ{+gdF~asLO+w&g3d8P1LI)Sf7s{7PwXss5Bn{j7RLj1lE+G} zK-lE(4W`8Dfu8E@TJ?av#~!S4!jj%Q_`SY2`%tmExTJ&>tBl2iIgcE+i+siwUR3=C zl}Nkx^a@HIBZ=zk0cuuKnv_Fs@0aoxHNtD%zc#}0W`~J?0Ae<0B{0e~EL$6i|EbyW zdrP6#ZVM)9xa#lpNW$l>3XwzGB5g%h_&GGs{0l*tQ_1crH8U7HO zek_jkzG5>w=mX28#-Vr{S*$DN_&>_V5z7L|WAXUle+dh=4WIqrWgDDzB5JO{WM?&U zm{A@eq`qgwL9E$#Pd{%k@TVXaEPrqM=x`rIux^j%;}~6gu!f+Jz>9$QFu@=JVEzCC zI-mfMfp7a+2zV+)4}WdzJ+&T1M(C5A@J6q^i#N7dt7ZABs-}5FHGxVP>p@9^0n3Zf z*7*geO9kidYvh{=V0ey;6qur%94Zr7acFEw`jSX5WM~QRYy{`bg$~wiuZX*P{d9!N z>QRs2tfcGk(4hj3Z*4oo%*P$8x{*DSs>mA*&Oeyfp985fA=~hycR_DUQQYZXEW|KGjgaCa1|CCd%frKqZh=8Kr07bpgjN!VDUg000YUj zw@{N7b6#(OdKvojle`ZlunzQ#K0H@*{@wbS4v7_m#)M`EWzgxrJ4Pr$<0dQFNe6|8 zg&qD$Mhf<+s=NJAYpO41O{0EPw(t^a*{+FY7*SqzMvfh4{NI}7_EkR94ARWgOb+hu zc`iCL&RiS)0~l$=9xJ%t$0$4sbYi1O;=rIV{z4(Cf;6B33P=K|9{><_URs!z(UWLc zH&*PW`ULX0{adC=lU}z*CKH*l*qo(X6>y27wBd`{Z(G9~w*Vf((pHMlPobk) zehfC6{6^v-ACf>YzyKDk9v}!+28_XA1+p*!s2cpS1DrR-)DfE|JzfMpn%MO;{%=#um)N68{5fJ= (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() + + +# --- beam shape ------------------------------------------------------------- + + +def upturn(x): + return UPTURN * (x / HALF_L) ** UPTURN_POW + + +def saddle(x): + """Raise of the beam's underside over each neck: a cosine bump, even in x.""" + d = abs(abs(x) - BOW_X) + if d >= SADDLE_HALF: + return 0.0 + return SADDLE_DEPTH * 0.5 * (1.0 + math.cos(math.pi * d / SADDLE_HALF)) + + +def beam_top(x): + return TOP_Z + CROWN * math.cos(math.pi * x / (2.0 * HALF_L)) ** 2 + upturn(x) + + +def beam_bottom(x): + return BOTTOM_Z + saddle(x) + upturn(x) + + +def beam_width(x): + return BEAM_W - BEAM_W_TAPER * (x / HALF_L) ** 2 + + +def beam_stations(leg_xs, bow_xs): + """Stations for x >= 0, mirrored: a uniform run plus one at every leg and neck. + + A station on each leg and bow centre puts a beam ring exactly where a pin rests, so + the top the pin is measured against is a vertex, not a chord. + """ + pos = {round(HALF_L * k / BEAM_STATIONS, 6) for k in range(BEAM_STATIONS + 1)} + pos |= {round(abs(x), 6) for x in list(leg_xs) + list(bow_xs)} + pos = sorted(pos) + return sorted({-x for x in pos} | set(pos)) + + +def section(x, k): + """Superellipse section vertex ``k`` of BEAM_SEG at station ``x``: (y, z).""" + phi = 2.0 * math.pi * k / BEAM_SEG + c, s = math.cos(phi), math.sin(phi) + e = 2.0 / SECTION_N + zc = (beam_top(x) + beam_bottom(x)) * 0.5 + hh = (beam_top(x) - beam_bottom(x)) * 0.5 + y = beam_width(x) * 0.5 * math.copysign(abs(c) ** e, c) + z = zc + hh * math.copysign(abs(s) ** e, s) + return y, z + + +# --- construction ----------------------------------------------------------- + + +def new_island(ctx): + ctx["next"] += 1 + return ctx["next"] + + +def stamp(ctx, face, island, uvmap, grain): + face[ctx["isl"]] = island + for loop in face.loops: + loop[ctx["uv"]].uv = uvmap[loop.vert] + face[ctx["gx"]], face[ctx["gy"]], face[ctx["gz"]] = grain + + +def tube(bm, rings, closed_ends, mat_idx, ctx, grains): + """Quads between consecutive rings, fans to the two end poles.""" + island = new_island(ctx) + n = len(rings[0]) + arc = [0.0] + for a, b in zip(rings, rings[1:]): + ca = sum((v.co for v in a), Vector()) / n + cb = sum((v.co for v in b), Vector()) / n + arc.append(arc[-1] + (cb - ca).length) + for k in range(len(rings) - 1): + a, b = rings[k], rings[k + 1] + 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 + stamp(ctx, f, island, {a[i]: (arc[k], i / n), a[j]: (arc[k], (i + 1) / n), + b[j]: (arc[k + 1], (i + 1) / n), b[i]: (arc[k + 1], i / n)}, + grains[k]) + for pole, ring, s, g, sign in ((closed_ends[0], rings[0], arc[0], grains[0], -1.0), + (closed_ends[1], rings[-1], arc[-1], grains[-1], 1.0)): + for i in range(n): + j = (i + 1) % n + vs = (pole, ring[i], ring[j]) if sign < 0 else (pole, ring[j], ring[i]) + f = bm.faces.new(vs) + f.material_index = mat_idx + stamp(ctx, f, island, {pole: (s + sign * 0.01, (i + 0.5) / n), + ring[i]: (s, i / n), ring[j]: (s, (i + 1) / n)}, g) + + +def add_beam(bm, ctx, leg_xs, bow_xs): + xs = beam_stations(leg_xs, bow_xs) + rings = [] + for x in xs: + rings.append([bm.verts.new((x, *section(x, k))) for k in range(BEAM_SEG)]) + z0 = (beam_top(xs[0]) + beam_bottom(xs[0])) * 0.5 + z1 = (beam_top(xs[-1]) + beam_bottom(xs[-1])) * 0.5 + poles = (bm.verts.new((xs[0] - END_REACH, 0.0, z0)), bm.verts.new((xs[-1] + END_REACH, 0.0, z1))) + tube(bm, rings, poles, OAK_IDX, ctx, [(1.0, 0.0, 0.0)] * len(rings)) + + +def sweep(bm, pts, radius, seg, side, mat_idx, ctx, cone=0.35, grain=None): + """Round section swept along ``pts`` with its frame fixed to the path's plane. + + ``side`` is the plane's normal; the section's vertex 3*seg/4 points + down the path's ``up`` (t x side) negative, so a path's lowest point + puts a vertex exactly ``radius`` below it. Ends close in blunt cones. + + ``grain`` fixes one grain direction for the whole shell. The shader + stretches its noise along the face's direction, so a per-ring tangent + breaks the figure at every ring: the bent bows rendered as bamboo. + """ + m = len(pts) + tans = [(pts[min(i + 1, m - 1)] - pts[max(i - 1, 0)]).normalized() for i in range(m)] + rings = [] + for p, t in zip(pts, tans): + s = (side - t * side.dot(t)).normalized() + up = t.cross(s) + rings.append([bm.verts.new(p + s * (radius * math.cos(2 * math.pi * k / seg)) + + up * (radius * math.sin(2 * math.pi * k / seg))) + for k in range(seg)]) + poles = (bm.verts.new(pts[0] - tans[0] * (cone * radius)), + bm.verts.new(pts[-1] + tans[-1] * (cone * radius))) + grains = [grain] * m if grain is not None else [tuple(t) for t in tans] + tube(bm, rings, poles, mat_idx, ctx, grains) + + +def bow_path(cx, a, tip_l, tip_r, lift=0.0): + """U centreline: left leg down, half-ellipse under the neck, right leg up.""" + pts = [] + zc = BOW_ARC_Z + lift + b = zc - (BOW_R + lift) + for k in range(6): + pts.append(Vector((cx - a, 0.0, tip_l + (zc - tip_l) * k / 6))) + for k in range(BOW_ARC_STEPS + 1): + th = math.pi + math.pi * k / BOW_ARC_STEPS + pts.append(Vector((cx + a * math.cos(th), 0.0, zc + b * math.sin(th)))) + for k in range(1, 7): + pts.append(Vector((cx + a, 0.0, zc + (tip_r - zc) * k / 6))) + return pts + + +def lathe_y(bm, profile, n, mat_idx, ctx, origin): + """Revolve an (r, t) profile about an axis along +Y from ``origin``.""" + xf = Matrix.Translation(origin) @ Matrix.Rotation(-math.pi / 2.0, 4, "X") + rings, poles = [], [] + for p in profile: + if p.x <= 0.0: + poles.append(bm.verts.new(xf @ Vector((0.0, 0.0, p.y)))) + else: + rings.append([bm.verts.new(xf @ Vector((p.x * math.cos(2 * math.pi * k / n), + p.x * math.sin(2 * math.pi * k / n), p.y))) + for k in range(n)]) + tube(bm, rings, poles, mat_idx, ctx, [(0.0, 1.0, 0.0)] * len(rings)) + + +def pin_profile(): + """A forged pin: shank with a flat head at the far end, from its near end.""" + h = PIN_L + # Two shank rings over the beam's crown, so the seat is measured on + # vertices where the pin rests rather than on a span between its ends. + rings = [(PIN_R * 0.7, 0.0), (PIN_R, 0.004), (PIN_R, h * 0.5 - 0.02), (PIN_R, h * 0.5 + 0.02), + (PIN_R, h - 0.010), (PIN_R * 1.7, h - 0.008), + (PIN_R * 1.7, h - 0.002), (PIN_R * 1.2, h)] + return [Vector((0.0, 0.0))] + [Vector(r) for r in rings] + [Vector((0.0, h))] + + +def add_ring(bm, centre, normal_axis, ctx): + """Torus of RING_R by RING_T in the plane whose normal is ``normal_axis``.""" + n = Vector(normal_axis) + u = Vector((0.0, 0.0, 1.0)) if abs(n.z) < 0.9 else Vector((1.0, 0.0, 0.0)) + v = n.cross(u) + rings = [] + for i in range(RING_SEG): + a = 2.0 * math.pi * i / RING_SEG + radial = u * math.cos(a) + v * math.sin(a) + c = centre + radial * RING_R + rings.append([bm.verts.new(c + radial * (RING_T * math.cos(2 * math.pi * k / RING_TUBE_SEG)) + + n * (RING_T * math.sin(2 * math.pi * k / RING_TUBE_SEG))) + for k in range(RING_TUBE_SEG)]) + island = new_island(ctx) + for i in range(RING_SEG): + a, b = rings[i], rings[(i + 1) % RING_SEG] + tang = tuple((b[0].co - a[0].co).normalized()) + for k in range(RING_TUBE_SEG): + j = (k + 1) % RING_TUBE_SEG + f = bm.faces.new((a[k], a[j], b[j], b[k])) + f.material_index = IRON_IDX + stamp(ctx, f, island, {a[k]: (i / RING_SEG, k / RING_TUBE_SEG), + a[j]: (i / RING_SEG, (k + 1) / RING_TUBE_SEG), + b[j]: ((i + 1) / RING_SEG, (k + 1) / RING_TUBE_SEG), + b[k]: ((i + 1) / RING_SEG, k / RING_TUBE_SEG)}, tang) + + +def build_yoke_mesh( + name, + stray_vert=False, + float_bow=False, + short_bows=False, + short_staple=False, + edge_on_ring=False, + float_pins=False, + clip_ring=False, + pinch_bows=False, + skew_bow=False, +): + a = PINCH_A if pinch_bows else BOW_A + bows = [] + for side in (-1.0, 1.0): + cx = side * BOW_X + (SKEW_BOW if (skew_bow and side > 0) else 0.0) + bows.append((cx, side)) + leg_xs = [cx + d for cx, _s in bows for d in (-a, a)] + bm = bmesh.new() + try: + ctx = {"uv": bm.loops.layers.uv.new("UVMap"), + "isl": bm.faces.layers.int.new("UVIsland"), + "gx": bm.faces.layers.float.new("gx"), + "gy": bm.faces.layers.float.new("gy"), + "gz": bm.faces.layers.float.new("gz"), "next": 0} + add_beam(bm, ctx, leg_xs, [cx for cx, _s in bows]) + for cx, side in bows: + lift = FLOAT_BOW if (float_bow and side < 0) else 0.0 + protrude = SHORT_BOW if short_bows else BOW_PROTRUDE + tip_l = beam_top(cx - a) + protrude + tip_r = beam_top(cx + a) + protrude + sweep(bm, bow_path(cx, a, tip_l, tip_r, lift=lift), BOW_R, BOW_SEG, + Vector((0.0, 1.0, 0.0)), HICKORY_IDX, ctx, grain=(0.0, 0.0, 1.0)) + # Pins across each leg tip, resting on the beam's top at that leg. + for lx in (cx - a, cx + a): + pz = beam_top(lx) + PIN_R - PIN_SEAT + (FLOAT_PIN if float_pins else 0.0) + lathe_y(bm, pin_profile(), 12, IRON_IDX, ctx, + Vector((lx, -PIN_L * 0.5, pz))) + # Staple under the centre: a round bar bent into a U across Y, its + # legs driven up into the beam. + bb = beam_bottom(0.0) + top = bb + (SHORT_STAPLE if short_staple else STAPLE_BITE) + bar_z = bb - STAPLE_DROP + pts = [Vector((0.0, -STAPLE_C, top + (bar_z - top) * k / 4)) for k in range(4)] + for k in range(13): + th = math.pi + math.pi * k / 12 + pts.append(Vector((0.0, STAPLE_C * math.cos(th), bar_z + STAPLE_C * math.sin(th)))) + pts += [Vector((0.0, STAPLE_C, bar_z + (top - bar_z) * k / 4)) for k in range(1, 5)] + sweep(bm, pts, STAPLE_R, 12, Vector((1.0, 0.0, 0.0)), IRON_IDX, ctx) + # The ring hangs on the staple's bar: its inner edge rests on the + # bar's top, and its hole runs along the bar (Y). + low = bar_z - STAPLE_C + rc = Vector((0.0, 0.0, low + STAPLE_R + RING_T - RING_R - RING_BITE + + (CLIP_RING if clip_ring else 0.0))) + add_ring(bm, rc, (1.0, 0.0, 0.0) if edge_on_ring else (0.0, 1.0, 0.0), ctx) + if stray_vert: + bm.verts.new((0.0, 0.0, 0.1)) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for f in bm.faces: + f.smooth = True + for e in bm.edges: + if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > math.radians(40.0): + e.smooth = False + pack_uvs(bm, ctx) + bm.faces.layers.int.remove(ctx["isl"]) + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + paint_pieces(me) + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def pack_uvs(bm, ctx, margin=0.06): + """One grid cell per UV island (every face here belongs to a strip island).""" + uv, isl = ctx["uv"], ctx["isl"] + islands, order = {}, [] + for face in bm.faces: + key = face[isl] + 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)) + cw, ch = 1.0 / cols, 1.0 / rows + pu, pv = margin * cw * 0.5, margin * ch * 0.5 + for idx, key in enumerate(order): + faces = islands[key] + allc = [tuple(loop[uv].uv) for f in faces for loop in f.loops] + minx, maxx = min(c[0] for c in allc), max(c[0] for c in allc) + miny, maxy = min(c[1] for c in allc), max(c[1] for c in allc) + dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8) + ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv + for face in faces: + for loop in face.loops: + x, y = loop[uv].uv + loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu), + ov + (y - miny) / dy * (ch - 2 * pv)) + + +def paint_pieces(me): + """``GrainDir`` from the per-face path tangent, ``PlankTone`` per shell.""" + npoly = len(me.polygons) + comps = [] + for nm in ("gx", "gy", "gz"): + vals = [0.0] * npoly + me.attributes[nm].data.foreach_get("value", vals) + comps.append(vals) + me.attributes.remove(me.attributes[nm]) + grain = [c for i in range(npoly) for c in (comps[0][i], comps[1][i], comps[2][i])] + tone = [0.5] * npoly + vf = [[] for _ in range(len(me.vertices))] + for p in me.polygons: + for i in p.vertices: + vf[i].append(p.index) + for k, g in enumerate(shells(me)): + t = 0.5 + 0.3 * (((k * 0.6180339887 + 0.3) % 1.0) - 0.5) + for fi in {fi for i in g for fi in vf[i]}: + tone[fi] = t + 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", grain) + + +# --- surface ---------------------------------------------------------------- + + +def _sock(sockets, identifier): + return next(sk for sk in sockets if sk.identifier == identifier) + + +def wood_material(name, dark, light, rough=(0.72, 0.52)): + """Timber whose grain runs along ``GrainDir`` and whose tone varies by piece.""" + 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 = 30.0 + 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 = (*dark, 1.0) + ramp.color_ramp.elements[1].position = 0.72 + ramp.color_ramp.elements[1].color = (*light, 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.0 + gain.inputs[2].default_value = 0.50 + 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"]) + rmap = nt.nodes.new("ShaderNodeMapRange") + rmap.inputs["To Min"].default_value = rough[0] + rmap.inputs["To Max"].default_value = rough[1] + nt.links.new(noise.outputs["Fac"], rmap.inputs["Value"]) + nt.links.new(rmap.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def iron_material(name): + """Forged iron: near-black, rough, rusted in patches. Not chrome.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + tc = nt.nodes.new("ShaderNodeTexCoord") + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 40.0 + noise.inputs["Detail"].default_value = 8.0 + nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.45 + ramp.color_ramp.elements[0].color = (0.035, 0.033, 0.031, 1.0) + ramp.color_ramp.elements[1].position = 0.78 + ramp.color_ramp.elements[1].color = (0.20, 0.085, 0.035, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) + bsdf.inputs["Metallic"].default_value = 0.65 + rough = nt.nodes.new("ShaderNodeMapRange") + rough.inputs["To Min"].default_value = 0.55 + rough.inputs["To Max"].default_value = 0.85 + nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) + nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def yoke_materials(): + return ( + wood_material("YokeOak", (0.075, 0.040, 0.018), (0.25, 0.14, 0.065)), + wood_material("YokeHickory", (0.13, 0.075, 0.035), (0.36, 0.22, 0.11), rough=(0.66, 0.46)), + iron_material("YokeIron"), + ) + + +def assign_slots(obj, mats): + slots = obj.data.materials + for i, mat in enumerate(mats): + if i < len(slots): + slots[i] = mat + else: + slots.append(mat) + + +# --- measurement ------------------------------------------------------------ + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [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 face_area(me, poly): + idxs = poly.vertices + v0 = me.vertices[idxs[0]].co + area = 0.0 + for i in range(1, len(idxs) - 1): + area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5 + return area + + +def hygiene_audit(me): + 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) + 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 {"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* (copied from showcase/grindstone).""" + 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 classify(me): + mats = {} + for p in me.polygons: + for i in p.vertices: + mats.setdefault(i, p.material_index) + out = {"beam": [], "bow": [], "pin": [], "staple": [], "ring": [], "other": []} + for g in shells(me): + pts = [me.vertices[i].co.copy() for i in g] + lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": hi - lo, + "c": sum(pts, Vector()) / len(pts)} + m = mats.get(g[0], -1) + if m == OAK_IDX: + out["beam"].append(rec) + elif m == HICKORY_IDX: + out["bow"].append(rec) + elif m == IRON_IDX: + e = rec["ext"] + if e.z < 4.0 * PIN_R and e.y > 0.05: + out["pin"].append(rec) + elif min(e.x, e.y) < 2.5 * RING_T + 1e-3 and max(e.x, e.y) > 0.09: + out["ring"].append(rec) + else: + out["staple"].append(rec) + else: + out["other"].append(rec) + return out + + +def yoke_audit(me): + parts = classify(me) + out = {k: len(v) for k, v in parts.items()} + beam = parts["beam"][0]["pts"] if parts["beam"] else [] + + def beam_top_at(x): + near = [p.z for p in beam if abs(p.x - x) < 0.002] + return max(near) if near else 99.0 + + def beam_bottom_at(x): + near = [p.z for p in beam if abs(p.x - x) < 0.002] + return min(near) if near else -99.0 + + bows = sorted(parts["bow"], key=lambda r: r["c"].x) + out["bow_z"] = max((r["lo"].z for r in bows), default=99.0) + + # Legs pass up through the beam and stand proud of it. + protrude, widths, heights = [], [], [] + for r in bows: + cx = (r["lo"].x + r["hi"].x) * 0.5 + for sgn in (-1.0, 1.0): + leg = [p for p in r["pts"] if (p.x - cx) * sgn > 0 and p.z > BOTTOM_Z] + if not leg: + continue + lx = sum(p.x for p in leg) / len(leg) + protrude.append(max(p.z for p in leg) - beam_top_at(lx)) + # Neck opening: between the legs' inner faces at mid-height, and from + # the bow's inner bottom up to the saddle. + mid = [p for p in r["pts"] if BOW_ARC_Z + 0.01 < p.z < BOW_ARC_Z + 0.15] + left = [p.x for p in mid if p.x < cx] + right = [p.x for p in mid if p.x > cx] + if left and right: + widths.append(min(right) - max(left)) + under = [p.z for p in r["pts"] if abs(p.x - cx) < 0.002 and p.z < BOW_ARC_Z] + if under: + heights.append(beam_bottom_at(cx) - max(under)) + out["protrude"] = (min(protrude, default=-99.0), max(protrude, default=99.0)) + out["n_legs"] = len(protrude) + out["neck_w"] = (min(widths, default=-99.0), max(widths, default=99.0)) + out["neck_h"] = (min(heights, default=-99.0), max(heights, default=99.0)) + + # Mirrored bows: one the image of the other through x = 0. + mirror = 99.0 + if len(bows) == 2: + a, b = bows + mirror = max(abs(a["lo"].x + b["hi"].x), abs(a["hi"].x + b["lo"].x), + abs(a["lo"].z - b["lo"].z), abs(a["hi"].z - b["hi"].z), + abs(a["lo"].y - b["lo"].y), abs(a["hi"].y - b["hi"].y)) + out["mirror"] = mirror + + # Pins rest on the beam's top at their leg. + # Measured on the shank over the beam; the head hangs past the beam's edge. + seats = [beam_top_at(r["c"].x) - min(p.z for p in r["pts"] if abs(p.y) < 0.03) + for r in parts["pin"]] + out["pin_seat"] = (min(seats, default=-99.0), max(seats, default=99.0)) + + # Staple driven into the beam; ring threaded on, and hanging from, its bar. + out["staple_bite"] = -99.0 + out["thread"] = 0.0 + out["ring_gap"] = 99.0 + if parts["staple"] and parts["ring"]: + st = parts["staple"][0] + out["staple_bite"] = st["hi"].z - beam_bottom_at(st["c"].x) + r_s = st["ext"].x * 0.5 + bar = Vector((st["c"].x, (st["lo"].y + st["hi"].y) * 0.5, st["lo"].z + r_s)) + normal_s = min(range(3), key=lambda k: st["ext"][k]) + bar_dir = Vector((0.0, 0.0, 1.0)).cross(Vector([1.0 if k == normal_s else 0.0 + for k in range(3)])).normalized() + rg = parts["ring"][0] + c = rg["c"] + nk = min(range(3), key=lambda k: rg["ext"][k]) + n = Vector([1.0 if k == nk else 0.0 for k in range(3)]) + radial = [((p - c) - n * (p - c).dot(n)).length for p in rg["pts"]] + r_in, r_out = min(radial), max(radial) + d = bar - c + in_plane = (d - n * d.dot(n)).length + out["thread"] = abs(n.dot(bar_dir)) + out["ring_gap"] = r_in - (in_plane + r_s) + out["ring_tube"] = (r_out - r_in) * 0.5 + return out + + +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 ring_sample(me, group, seg, ring_step, vert_step, keep=None): + """Every ``ring_step``-th ring and ``vert_step``-th vertex of a swept shell. + + Vertices are laid ring after ring in build order, so index order is + ring order; the two end poles are the last two indices and are kept. + """ + order = sorted(group) + body, poles = order[:-2], order[-2:] + nrings = len(body) // seg + pts = [] + for r in range(nrings): + if r % ring_step and r != nrings - 1: + continue + ring = [me.vertices[body[r * seg + k]].co.copy() for k in range(0, seg, vert_step)] + if keep is None or keep(sum(ring, Vector()) / len(ring)): + pts += ring + return pts + [me.vertices[i].co.copy() for i in poles if keep is None + or keep(me.vertices[i].co)] + + +def hull_collider(obj, name): + """Compound collider: one hull for the beam, one per bow leg and one per bow foot. + + A single hull over a bow fills the neck opening, which is exactly the + space the yoke exists to leave open. Hulls are taken over sampled rings + of each swept shell, not over every vertex. + """ + me = obj.data + parts = classify(me) + groups = [] + if parts["beam"]: + groups.append(ring_sample(me, parts["beam"][0]["g"], BEAM_SEG, 8, 2)) + for r in parts["bow"]: + cx = (r["lo"].x + r["hi"].x) * 0.5 + g = r["g"] + groups.append(ring_sample(me, g, BOW_SEG, 5, 2, + lambda c, cx=cx: c.x < cx and c.z > BOW_ARC_Z - 0.01)) + groups.append(ring_sample(me, g, BOW_SEG, 5, 2, + lambda c, cx=cx: c.x > cx and c.z > BOW_ARC_Z - 0.01)) + groups.append(ring_sample(me, g, BOW_SEG, 4, 2, lambda c: c.z <= BOW_ARC_Z + 0.01)) + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for pts in groups: + if len(pts) < 4: + continue + tmp = bmesh.new() + try: + vs = [tmp.verts.new(p) for p in pts] + bmesh.ops.convex_hull(tmp, input=vs) + remap = {} + for f in tmp.faces: + for v in f.verts: + if v not in remap: + remap[v] = bm.verts.new(v.co) + bm.faces.new([remap[v] for v in f.verts]) + finally: + tmp.free() + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + col = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(col) + return col + + +def setup_bake_image(obj, target_mat, size): + img = bpy.data.images.new("YokeNrm", 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 = OAK_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, **flags): + bpy.ops.wm.read_factory_settings(use_empty=True) + nothing = (None,) * 5 + low = build_yoke_mesh("YokeLow", **flags) + hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"} + high = build_yoke_mesh("YokeHigh", **hi_flags) + mats = yoke_materials() + assign_slots(low, mats) + assign_slots(high, mats) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() + if len(low.data.polygons) < 6 or not low.data.uv_layers: + return (fail("yoke mesh did not build, or has no UV layer", 3),) + nothing + + base_tris = triangle_count(low.data) + slots = [s for s in low.data.materials if s is not None] + nmat, distinct = len(slots), len({id(s) for s in slots}) + 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] + + img, tex = setup_bake_image(low, mats[OAK_IDX], BAKE_RES) + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "YokeLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "YokeLOD2", 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 = hull_collider(high, "YokeCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join(tempfile.gettempdir(), f"bdt_yoke_{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) + ya = yoke_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}") + 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}") + print(f"measured parts beam={ya['beam']} bows={ya['bow']} pins={ya['pin']} " + f"staple={ya['staple']} ring={ya['ring']} other={ya['other']} " + f"bow_z={ya['bow_z']:.5f} legs={ya['n_legs']}") + print(f"measured joints protrude=({ya['protrude'][0]:.5f},{ya['protrude'][1]:.5f}) " + f"staple_bite={ya['staple_bite']:.5f} thread={ya['thread']:.4f}") + print(f"measured seats pin=({ya['pin_seat'][0]:.5f},{ya['pin_seat'][1]:.5f}) " + f"ring_gap={ya['ring_gap']:.5f}") + print(f"measured neck width=({ya['neck_w'][0]:.5f},{ya['neck_w'][1]:.5f}) " + f"height=({ya['neck_h'][0]:.5f},{ya['neck_h'][1]:.5f}) mirror={ya['mirror']:.6f}") + + 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 + for idx, floor in FACE_FLOORS.items(): + if idx_counts.get(idx, 0) < floor: + return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 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 {hyg} 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 ya["bow"] != 2 or ya["bow_z"] > BOW_Z_MAX: + return (fail(f"bows: {ya['bow']} of 2, worst bow z={ya['bow_z']:.5f} > {BOW_Z_MAX} " + "(--float-bow is the designed fail)", 16),) + nothing + if (ya["n_legs"] != 4 or ya["protrude"][0] < PROTRUDE_MIN + or ya["protrude"][1] > PROTRUDE_MAX): + return (fail(f"{ya['n_legs']} of 4 legs, protrusion above the beam {ya['protrude']} " + f"outside [{PROTRUDE_MIN}, {PROTRUDE_MAX}] (--short-bows is the designed fail)", + 17),) + nothing + if not (STAPLE_BITE_MIN <= ya["staple_bite"] <= STAPLE_BITE_MAX): + return (fail(f"staple bite {ya['staple_bite']:.5f} outside [{STAPLE_BITE_MIN}, " + f"{STAPLE_BITE_MAX}] (--short-staple is the designed fail)", 17),) + nothing + if ya["ring"] != 1 or ya["thread"] < THREAD_COS_MIN: + return (fail(f"{ya['ring']} ring, hole axis against the bar {ya['thread']:.4f} < " + f"{THREAD_COS_MIN:.4f} (--edge-on-ring is the designed fail)", 17),) + nothing + if ya["pin"] != 4 or ya["pin_seat"][0] < PIN_SEAT_MIN or ya["pin_seat"][1] > PIN_SEAT_MAX: + return (fail(f"{ya['pin']} of 4 pins, seat {ya['pin_seat']} outside [{PIN_SEAT_MIN}, " + f"{PIN_SEAT_MAX}] (--float-pins is the designed fail)", 18),) + nothing + if not (RING_GAP_MIN <= ya["ring_gap"] <= RING_GAP_MAX): + return (fail(f"ring hangs {ya['ring_gap']:.5f} off its bar, outside [{RING_GAP_MIN}, " + f"{RING_GAP_MAX}] (--clip-ring is the designed fail)", 18),) + nothing + if (not (NECK_W_MIN <= ya["neck_w"][0] and ya["neck_w"][1] <= NECK_W_MAX) + or not (NECK_H_MIN <= ya["neck_h"][0] and ya["neck_h"][1] <= NECK_H_MAX)): + return (fail(f"neck opening width {ya['neck_w']} (band [{NECK_W_MIN}, {NECK_W_MAX}]), " + f"height {ya['neck_h']} (band [{NECK_H_MIN}, {NECK_H_MAX}]) " + "(--pinch-bows is the designed fail)", 19),) + nothing + if ya["mirror"] > MIRROR_EPS: + return (fail(f"bows not mirrored: {ya['mirror']:.6f} > {MIRROR_EPS} " + "(--skew-bow is the designed fail)", 19),) + nothing + return 0, low, high, mats, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + nrm = nt.nodes.new("ShaderNodeNormalMap") + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"]) + + +def render_still(low, mats, tex, path, engine): + scene = bpy.context.scene + wire_normal(mats[OAK_IDX], 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(-22.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, 8.5, 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, kind, loc, energy, size, col, rot=(0, 0, 0)): + ld = bpy.data.lights.new(name, kind) + ld.energy = energy + if kind == "AREA": + ld.size = size + else: + ld.shadow_soft_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", "AREA", (-2.4, -3.2, 3.0), 360.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35)) + light("Fill", "AREA", (3.2, -2.6, 1.4), 60.0, 5.0, (0.74, 0.84, 1.0), (70, 0, 50)) + light("Rim", "AREA", (-1.6, 2.6, 2.2), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200)) + ld = bpy.data.lights.new("Wedge", "SPOT") + ld.energy, ld.color = 220.0, (1.0, 0.66, 0.34) + ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(50.0), 1.0, 0.3 + wedge = bpy.data.objects.new("Wedge", ld) + wedge.location = (0.5, 1.6, 1.9) + wedge.rotation_euler = (Vector((0.2, 0.5, 0.0)) - wedge.location).to_track_quat( + "-Z", "Y").to_euler() + scene.collection.objects.link(wedge) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (0.95, -2.35, 1.05) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.30) + 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-bow", action="store_true") + p.add_argument("--short-bows", action="store_true") + p.add_argument("--short-staple", action="store_true") + p.add_argument("--edge-on-ring", action="store_true") + p.add_argument("--float-pins", action="store_true") + p.add_argument("--clip-ring", action="store_true") + p.add_argument("--pinch-bows", action="store_true") + p.add_argument("--skew-bow", action="store_true") + args = p.parse_args(argv) + + code, low, _high, mats, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_bow=args.float_bow, + short_bows=args.short_bows, + short_staple=args.short_staple, + edge_on_ring=args.edge_on_ring, + float_pins=args.float_pins, + clip_ring=args.clip_ring, + pinch_bows=args.pinch_bows, + skew_bow=args.skew_bow, + ) + if code: + return code + if args.output: + rcode = render_still(low, mats, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("wooden yoke 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 7ea77134..96d07ada 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -105,5 +105,6 @@ {"name": "rope-bridge", "script": "showcase/rope-bridge/rope_bridge.py"}, {"name": "grain-sacks", "script": "showcase/grain-sacks/grain_sacks.py"}, {"name": "brazier", "script": "showcase/brazier/brazier.py"}, - {"name": "apothecary-shelf", "script": "showcase/apothecary-shelf/apothecary_shelf.py"} + {"name": "apothecary-shelf", "script": "showcase/apothecary-shelf/apothecary_shelf.py"}, + {"name": "wooden-yoke", "script": "showcase/wooden-yoke/wooden_yoke.py"} ]