Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/gallery/assets/cart-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/stone-well-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
364 changes: 350 additions & 14 deletions docs/gallery/cart/index.html

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/gallery/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,7 @@ <h2><a href="shipping-crate/">shipping-crate</a></h2>
<div class="card-body">
<h2><a href="stone-well/">stone-well</a></h2>
<p class="teaches">A procedural stone well through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.</p>
<p class="witnesses"><span class="tag">witnesses</span> Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.761 m, LOD ratios in band, convex collider 306 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.</p>
<p class="witnesses"><span class="tag">witnesses</span> Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.312×1.312×1.761 m, LOD ratios in band, convex collider 346 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, all 12 bottom-course stones on the floor, each post tenoned 18 mm into the measured coping and standing under a roof hip corner, the bucket hanging 0.10 m clear of the coping, grounded zmin, non-empty glTF. --stand-posts exits 15 on the z-fight budget; --float-stone exits 16 on the named-support budget; --shallow-tenon and --drop-bucket exit 18; --turn-posts exits 19 on post placement; --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.</p>
<a class="card-link" href="stone-well/">View showcase piece <span aria-hidden="true">&rarr;</span></a>
</div>
</article>
Expand Down Expand Up @@ -1002,7 +1002,7 @@ <h2><a href="watchtower/">watchtower</a></h2>
<div class="card-body">
<h2><a href="cart/">cart</a></h2>
<p class="teaches">A procedural two-wheel wooden cart through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.</p>
<p class="witnesses"><span class="tag">witnesses</span> Recomputed: 2600 tris, two materials with metal and wood face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.539×0.749×0.640 m, LOD ratios in band, convex collider 122 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.</p>
<p class="witnesses"><span class="tag">witnesses</span> Recomputed: 2856 tris, two materials with metal and wood face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.539×0.749×0.640 m, LOD ratios in band, convex collider 154 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, both tyres grounded, tyre seat depth in band over 48 angular stations, the two wheels mirrored within 5e-5 m, grounded zmin, non-empty glTF. --flush-tyre exits 15 on the z-fight budget; --sink-tyre exits 18 on the tyre seat band; --float-wheel exits 16 on the named-support budget; --skew-wheel exits 19 on the wheel mirror; --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.</p>
<a class="card-link" href="cart/">View showcase piece <span aria-hidden="true">&rarr;</span></a>
</div>
</article>
Expand Down
463 changes: 433 additions & 30 deletions docs/gallery/stone-well/index.html

Large diffs are not rendered by default.

34 changes: 33 additions & 1 deletion examples/ngon-triangulate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,38 @@ glTF tris = 12 for a cube *or* this mesh. `--skip-triangulate` exits 4.
No gallery still. A hexagon on a cube does not read at thumbnail
without fake annotation.

## Verified behaviour

`Mesh.calc_tangents` raises `RuntimeError` on any face with more than
four loops. The message is byte-identical on 4.5.11 LTS, 5.1.2, and
5.2.1 LTS:

```
Error: Tangent space can only be computed for tris/quads, aborting
```

`TANGENT_ABORT` matches on the `tris/quads` substring, so the check
survives a reword of the surrounding sentence but still fails if the
abort stops happening at all.

## Falsifiers

Each flag breaks one stage and lands on the assertion that stage feeds.
Neither announces a failure; both let a real check catch the mesh.

| Flag | What it breaks | Exit |
| --- | --- | --- |
| `--no-dissolve` | never makes the n-gon, so the pre-assertion finds no pathology (`ngon count 0 != 1`) | 3 |
| `--skip-triangulate` | skips `bmesh.ops.triangulate`, so the handling assertion sees the n-gon survive (`ngons=1 tris=0 quads=4`) | 4 |

## API reference

| Name | 4.5 LTS | 5.2 LTS |
| --- | --- | --- |
| `Mesh.calc_tangents` | [4.5](https://docs.blender.org/api/4.5/bpy.types.Mesh.html#bpy.types.Mesh.calc_tangents) | [5.2](https://docs.blender.org/api/current/bpy.types.Mesh.html#bpy.types.Mesh.calc_tangents) |
| `bmesh.ops.dissolve_edges` | [4.5](https://docs.blender.org/api/4.5/bmesh.ops.html#bmesh.ops.dissolve_edges) | [5.2](https://docs.blender.org/api/current/bmesh.ops.html#bmesh.ops.dissolve_edges) |
| `bmesh.ops.triangulate` | [4.5](https://docs.blender.org/api/4.5/bmesh.ops.html#bmesh.ops.triangulate) | [5.2](https://docs.blender.org/api/current/bmesh.ops.html#bmesh.ops.triangulate) |

## Run

```bash
Expand All @@ -41,7 +73,7 @@ against it.
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Pathology missing: n-gon count, loops, or face count (`--no-dissolve` lands here) |
| 4 | `calc_tangents` / triangulate handling (`--skip-triangulate` lands here) |
| 4 | `calc_tangents` / triangulate handling (`--skip-triangulate` lands here, via the real handling assertion) |

The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
Expand Down
17 changes: 9 additions & 8 deletions examples/ngon-triangulate/ngon_triangulate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@

* 5 faces, 1 n-gon, that face has 6 loops
* ``Mesh.calc_tangents`` aborts until triangulated
(same abort ``triangulate-tangents`` documents)
(same abort ``triangulate-tangents`` documents). The message is
byte-identical on 4.5.11 LTS, 5.1.2 and 5.2.1 LTS:
"Error: Tangent space can only be computed for tris/quads, aborting"
* triangulate the n-gon → 4 tris + 4 quads, 28 loops, tangents succeed

glTF tri count is 12 either way (hexagon+quads or a cube) — not a
Expand Down Expand Up @@ -117,13 +119,12 @@ def check(ob, skip_triangulate):
file=sys.stderr,
)
return 4
if skip_triangulate:
print(
"ERROR: skip-triangulate left the n-gon; handling unrepaired",
file=sys.stderr,
)
return 4
triangulate_ngons(me)
# --skip-triangulate skips the repair and lets the handling assertions
# below catch the unrepaired mesh. Returning 4 from here instead would
# make the flag announce a failure rather than cause one, and the
# assertions it is supposed to falsify would never run.
if not skip_triangulate:
triangulate_ngons(me)
leftover = ngons(me)
tris = sum(1 for p in me.polygons if len(p.vertices) == 3)
quads = sum(1 for p in me.polygons if len(p.vertices) == 4)
Expand Down
64 changes: 64 additions & 0 deletions showcase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ entry in `showcase/gallery.json`, and a rendered still.
coplanar-face budget fails (exit 15). `--short-stile` /
`--short-post` lift a member out of its cup (exit 18 or 19).
`--clip-ring` pulls a hung ring off the eye centerline (exit 18).
`--flush-tyre` / `--stand-posts` restore a flush surface so the
coplanar budget fails (exit 15). `--float-wheel` / `--float-stone`
lift one named support while the rest still ground the AABB (exit 16).
`--sink-tyre` / `--shallow-tenon` put a seat outside its band (exit
18). `--drop-bucket` hides the hung subject (exit 18).
`--skew-wheel` / `--turn-posts` break a mirror or placement budget
(exit 19).
A budget with no falsifier witnesses nothing:
prove each one fails once, and check the exit code, not just
non-zero.
Expand Down Expand Up @@ -187,6 +194,63 @@ entry in `showcase/gallery.json`, and a rendered still.
The outer AABB does not cover this: on a prop with an appendage the
AABB is the appendage, and the body can drift to any size underneath
it.
- **A band is hooped onto its host, never set flush against it (exit
15/18).** A tyre, ferrule, hoop or collar derives its **inner** radius
from the host's **outer** radius minus a named interference, and its
outer radius from the host plus its own thickness. Writing
`r_mid = host_out + t/2, radial_t = t/2` — the obvious spelling —
makes the band's inner cylinder and the host's tread the *same
surface*, so every segment is a coplanar cross-shell pair around the
whole circumference. `cart` shipped that way and measured 32 pairs
(16 per wheel); the speckle was visible on the committed hero and in
a clay pass, and no budget could see it. `--flush-tyre` restores the
equality and is the falsifier.
- **A member is tenoned into its seat, never stood on it (exit
15/18).** A post, leg or stile whose bottom face lands exactly on its
host's top face puts both on one plane. Derive the member's bottom
from the host's top minus a named seat depth, and hold the member's
*top* fixed so nothing above it moves. `stone-well` stood its four
roof posts on the coping and measured 4 pairs, one per post.
Assert the seat as a **band** recomputed against the host surface read
off the generated mesh — `max z` over the host's material — not
against the constant the builder used, which witnesses nothing.
- **Posts go under the roof's corners, not the middle of its eaves
(exit 19).** Where a hip or pyramid roof is carried on four posts,
assert each post's plan bearing against a hip corner **recomputed from
the generated mesh**, as a wrapped angular difference. Take the
corners from the *pooled* vertices at the eave line: pulling them from
a single shell picks one fascia board, whose own extremes sit 90° off
the corners it is nailed to, and the budget then fails on a correct
model. `stone-well` placed its posts on the axes, which cantilevered
the roof's corners 0.80 m and stood one post dead centre in the well
mouth in every orthographic view.
- **A roof is sized from what it must cover, not from what carries it.**
Deriving the eave reach from the post ring plus an overhang gave
`stone-well` a 1.64 m roof over a 1.08 m drum — an umbrella. Derive it
from the covered body's own radius plus a named clearance
(`EAVE_HALF = R_OUTER + CURB_OUT + EAVE_CLEAR`), and let the overhang
fall out of that. Expect the convex-hull collider budget to move when
the roof does; re-fit it and say so.
- **The subject hangs where it can be seen (exit 18).** A prop whose
story is one small part — a bucket on a rope, a lantern on a hook —
asserts that part's clearance above the body it hangs over, as a band,
measured against the body read off the mesh. `stone-well`'s bucket sat
down the shaft with only its rim level with the coping: invisible in
the hero and in all six orthographic views, and no budget noticed.
`--drop-bucket` is the falsifier.
- **Mirrored assemblies (exit 19).** Where a prop has a left and a
right of the same part — two wheels, two brackets — pair the shells
and assert they match in the two axes they share and in their extents,
within a named epsilon, and are opposite in the mirrored axis. Size
the falsifier's displacement to stay **inside** `BBOX_TOL` so the AABB
gate cannot steal the failure: `cart`'s `--skew-wheel` moves one wheel
6 mm along a track whose tolerance is 10 mm.
- **Segment counts are a silhouette budget, not a triangle budget.** A
16-gon felloe reads as a polygon at hero size, and the flat facet
facing the key light renders as a hard white plate. `cart` went to 24
and the chords disappeared. Re-fit the triangle band around the new
measured count rather than leaving the old one — and prefer a band
*narrower* than the one it replaces, centred on the measurement.
- **Material face floors.** Every declared material asserts a named
minimum face count on the finished mesh, recomputed from
`polygon.material_index`. This catches the slot-assignment wipe class:
Expand Down
52 changes: 39 additions & 13 deletions showcase/cart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,40 +21,64 @@ materials, UVs, evaluated LOD, collider, or export file.

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
| Base triangles | 2470–2900 | 2600 / 2600 / 2600 |
| Base triangles | 2760–2960 | 2856 / 2856 / 2856 |
| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2154 |
| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2157 |
| Materials | exactly 2 distinct, metal ≥ 24, wood ≥ 800 faces | 2 slots, floors met |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
| Outer AABB | (1.539, 0.749, 0.640) m ± 0.01 | (1.5393, 0.7486, 0.6400) |
| Grounded | bbox min Z within 1e-4 of 0 | 0.0000 / 0.0000 / 0.0000 |
| Named supports | each tyre's own zmin within 1e-4 of the floor | +Y 0.000000, −Y 0.000000 |
| Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis |
| Z-fighting | coplanar cross-shell face pairs = 0 | 0 / 0 / 0 |
| Tyre seat | 0.0030–0.0055 m interference, per angular station | [0.00400, 0.00400] over 48 stations |
| Wheel mirror | the two wheels match in X, Z and extent within 5e-5 m | 0.00000 mm |
| Material-island gap | metal↔wood min distance ≤ 0.008 m | 0.00000 / 0.00000 / 0.00000 |
| Collider tris | ≤ 360 | 122 |
| Export | written, size > 0 | 201048 / 201048 / 201032 bytes |
| Collider tris | ≤ 360 | 154 |
| Export | written, size > 0, removed after measuring | 221960 bytes on 5.2.1 |

DECIMATE COLLAPSE triangle counts are **not** identical across series —
5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an
exact count. Bake pixels are stochastic; the gate is `has_data` plus
operator `FINISHED`, not byte-identity. Construction uses no RNG.
Export byte counts differ by 8 B on 5.2.1 (glTF serializer), not a
gated axis.
Export byte counts differ by a few bytes across series (glTF
serializer); the gate is "written and non-empty", not a byte count, and
the file is removed once measured.

`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and
exit 9 fires. `--lift-z` raises the finished mesh 0.05 m so the grounded
budget fails and exit 16 fires. Those are the named budgets the two
falsifiers violate.
## Falsifiers

Each flag breaks one stage so a **named** budget fails and the piece
exits *that* code. Magnitudes are sized so no earlier gate can steal the
failure: the two wheel moves stay inside `BBOX_TOL`, so the AABB gate
(exit 8) still passes.

| Flag | Target budget | Exit |
| --- | --- | --- |
| `--skip-decimate` | LOD1 ratio band (ratio becomes 1.0) | 9 |
| `--lift-z` | AABB grounded zmin (whole mesh up 0.05 m) | 16 |
| `--flush-tyre` | Coplanar cross-shell pairs — sets the tyre's inner radius equal to the felloe's outer radius, which is the construction bug this piece was rebuilt to remove (0 → 48 pairs) | 15 |
| `--sink-tyre` | Tyre seat band — buries the hoop 9 mm into the felloe so it reads as one body | 18 |
| `--float-wheel` | Named supports — lifts one wheel 3 mm while the other still grounds the AABB | 16 |
| `--skew-wheel` | Wheel mirror — pushes one wheel 6 mm out along the track | 19 |

`--lift-z` and `--float-wheel` share exit 16 and fail different budgets:
`--lift-z` fails the AABB gate, which fires first; `--float-wheel` leaves
the AABB grounded, so only the per-tyre support check can catch it.

## Run

```bash
blender --background --python cart.py --
blender --background --python cart.py -- --skip-decimate
blender --background --python cart.py -- --lift-z
blender --background --python cart.py -- --flush-tyre
blender --background --python cart.py -- --sink-tyre
blender --background --python cart.py -- --float-wheel
blender --background --python cart.py -- --skew-wheel
blender --background --python cart.py -- --output cart.png
```

Smoke does not pass `--output`, `--skip-decimate`, or `--lift-z`.
Smoke passes none of the falsifier flags and no `--output`.

## Exit codes

Expand All @@ -78,6 +102,8 @@ File-local. `9` is a valid check code. `10` is reserved for
| 12 | Bake did not finish or image has no data |
| 13 | Export file missing or empty |
| 14 | `--output` produced no file |
| 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, or n-gons |
| 16 | Bbox min Z not grounded (`--lift-z` lands here) |
| 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, n-gons, or coplanar cross-shell face pairs (`--flush-tyre` lands here) |
| 16 | Bbox min Z not grounded (`--lift-z`), or a named support off the floor (`--float-wheel`) |
| 17 | Material-island gap above tolerance (parts meant to touch) |
| 18 | Tyre seat depth outside its band (`--sink-tyre` lands here) |
| 19 | Wheel mirror deviation above epsilon (`--skew-wheel` lands here) |
Loading
Loading