diff --git a/docs/gallery/assets/iron-cauldron-hero.webp b/docs/gallery/assets/iron-cauldron-hero.webp index c17a5b8..c455fc3 100644 Binary files a/docs/gallery/assets/iron-cauldron-hero.webp and b/docs/gallery/assets/iron-cauldron-hero.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 32315c8..2642789 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -1145,7 +1145,7 @@

tavern-stool

iron-cauldron

A procedural hanging iron cauldron with a bulged open pot, bail, and timber tripod through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.606×0.591×0.826 m, LOD ratios in band (5.2 COLLAPSE 4 tris leaner on LOD2), convex collider 180 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.013×0.986×0.827 m, pot–leg clearance 0.051 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 98 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

View example
diff --git a/docs/gallery/iron-cauldron/index.html b/docs/gallery/iron-cauldron/index.html index e7fb5c5..239ed7f 100644 --- a/docs/gallery/iron-cauldron/index.html +++ b/docs/gallery/iron-cauldron/index.html @@ -253,7 +253,7 @@

iron-cauldron

iron-cauldron render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.606×0.591×0.826 m, LOD ratios in band (5.2 COLLAPSE 4 tris leaner on LOD2), convex collider 180 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.013×0.986×0.827 m, pot–leg clearance 0.051 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 98 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
blender --background --python showcase/iron-cauldron/iron_cauldron.py --
@@ -264,7 +264,7 @@

iron-cauldron

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

Budgets

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

-

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 3280–3520 | 3388 / 3388 / 3388 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2184 | | Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 450 wood, 1198 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.606, 0.591, 0.826) m ± 0.01 | (0.6061, 0.5911, 0.8255), zmin 0.002 | | Collider tris | ≤ 200 | 180 | | Export | written, size > 0 | 250748 / 250748 / 250736 bytes |

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 3280–3520 | 3388 / 3388 / 3388 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2172 | | Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 450 wood, 1198 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.013, 0.986, 0.827) m ± 0.01 | (1.0128, 0.9865, 0.8271), zmin 0.002 | | Pot–leg clearance | ≥ 0.04 m (recomputed vs pole axes) | 0.0513 / 0.0513 / 0.0513 | | Collider tris | ≤ 200 | 98 | | Export | written, size > 0 | 245060 / 245060 / 245044 bytes |

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 4 tris leaner 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 12 B on 5.2.1 (glTF serializer), not a gated axis.

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

Run

@@ -274,7 +274,7 @@

Run

Smoke does not pass --output or --skip-decimate.

Exit codes

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.

-

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer / pot clips tripod | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

Source

@@ -330,12 +330,13 @@

Source

R_MID = 0.205 R_TOP = 0.148 APEX_Z = 0.82 -TRIPOD_R = 0.32 +TRIPOD_R = 0.56 BAIL_SEGS = 12 BAIL_R = 0.009 BBOX_TOL = 0.01 +POT_LEG_CLEARANCE_MIN = 0.04 # Fitted after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (0.606, 0.591, 0.826) +OUTER_SIZE = (1.013, 0.986, 0.827) BASE_TRIS_MIN = 3280 BASE_TRIS_MAX = 3520 @@ -726,6 +727,49 @@

Source

return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) +def pot_tripod_clearance(mesh): + # Recomputed from verts vs the three pole axes. Leg verts sit on the + # axes and are skipped; remaining shell verts in the pot band must + # clear the pole radius. + feet = [] + for i in range(3): + ang = i * (2.0 * math.pi / 3.0) + math.radians(18.0) + feet.append( + Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016)) + ) + apex = Vector((0.0, 0.0, APEX_Z)) + z0 = POT_Z0 - 0.02 + z1 = POT_Z0 + POT_H + 0.03 + min_c = None + for v in mesh.vertices: + p = Vector(v.co) + if p.z < z0 or p.z > z1: + continue + if math.hypot(p.x, p.y) < 0.06: + continue + nearest = None + on_pole = False + for foot in feet: + ab = apex - foot + denom = ab.length_squared + if denom < 1e-12: + continue + t = max(0.0, min(1.0, (p - foot).dot(ab) / denom)) + axis = (p - (foot + t * ab)).length + rad = 0.028 * (1.0 - t) + 0.018 * t + gap = axis - rad + if axis < rad + 0.006: + on_pole = True + break + if nearest is None or gap < nearest: + nearest = gap + if on_pole or nearest is None: + continue + if min_c is None or nearest < min_c: + min_c = nearest + return min_c if min_c is not None else -1.0 + + def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: @@ -868,6 +912,14 @@

Source

size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + clearance = pot_tripod_clearance(low.data) + print(f"measured pot_tripod_clearance={clearance:.4f}") + if clearance < POT_LEG_CLEARANCE_MIN: + return fail( + f"pot clips tripod clearance {clearance:.4f} " + f"< {POT_LEG_CLEARANCE_MIN}", + 3, + ), None, None, None, None, None img, tex = setup_bake_image(low, wood) if img is None: @@ -1046,10 +1098,10 @@

Source

cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.58, -2.18, 1.22) + cam.location = (1.82, -2.50, 1.40) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.46) + aim.location = (0.0, 0.0, 0.40) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim diff --git a/showcase/gallery.json b/showcase/gallery.json index da14762..96e4d22 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -285,7 +285,7 @@ "name": "iron-cauldron", "dir": "showcase/iron-cauldron", "teaches": "A procedural hanging iron cauldron with a bulged open pot, bail, and timber tripod through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.606×0.591×0.826 m, LOD ratios in band (5.2 COLLAPSE 4 tris leaner on LOD2), convex collider 180 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.013×0.986×0.827 m, pot–leg clearance 0.051 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 98 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", "hero": "docs/gallery/assets/iron-cauldron-hero.webp", "preview": "showcase/iron-cauldron/preview.webp", "tags": [ diff --git a/showcase/iron-cauldron/README.md b/showcase/iron-cauldron/README.md index e679d67..34857e2 100644 --- a/showcase/iron-cauldron/README.md +++ b/showcase/iron-cauldron/README.md @@ -23,18 +23,21 @@ materials, UVs, evaluated LOD, collider, or export file. | --- | --- | --- | | Base triangles | 3280–3520 | 3388 / 3388 / 3388 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2184 | +| LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2172 | | Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 450 wood, 1198 metal | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (0.606, 0.591, 0.826) m ± 0.01 | (0.6061, 0.5911, 0.8255), zmin 0.002 | -| Collider tris | ≤ 200 | 180 | -| Export | written, size > 0 | 250748 / 250748 / 250736 bytes | +| Outer AABB | (1.013, 0.986, 0.827) m ± 0.01 | (1.0128, 0.9865, 0.8271), zmin 0.002 | +| Pot–leg clearance | ≥ 0.04 m (recomputed vs pole axes) | 0.0513 / 0.0513 / 0.0513 | +| Collider tris | ≤ 200 | 98 | +| Export | written, size > 0 | 245060 / 245060 / 245044 bytes | DECIMATE COLLAPSE triangle counts are **not** identical across series — -5.2.1 is 4 tris leaner on LOD2. The gate is a ratio band, not an exact +5.2.1 is 8 tris leaner 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 12 B on 5.2.1 (glTF serializer), not a gated axis. +counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis. +The pot–leg clearance is recomputed from shell verts vs the three pole +axes; clipping exits 3. `--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates. @@ -59,7 +62,7 @@ File-local. `9` is a valid check code. `10` is reserved for | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | -| 3 | Mesh did not build / no UV layer | +| 3 | Mesh did not build / no UV layer / pot clips tripod | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing | | 6 | UVs outside 0..1 | diff --git a/showcase/iron-cauldron/iron_cauldron.py b/showcase/iron-cauldron/iron_cauldron.py index 5779151..5f7cf5c 100644 --- a/showcase/iron-cauldron/iron_cauldron.py +++ b/showcase/iron-cauldron/iron_cauldron.py @@ -46,12 +46,13 @@ R_MID = 0.205 R_TOP = 0.148 APEX_Z = 0.82 -TRIPOD_R = 0.32 +TRIPOD_R = 0.56 BAIL_SEGS = 12 BAIL_R = 0.009 BBOX_TOL = 0.01 +POT_LEG_CLEARANCE_MIN = 0.04 # Fitted after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (0.606, 0.591, 0.826) +OUTER_SIZE = (1.013, 0.986, 0.827) BASE_TRIS_MIN = 3280 BASE_TRIS_MAX = 3520 @@ -442,6 +443,49 @@ def world_bbox(obj): return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) +def pot_tripod_clearance(mesh): + # Recomputed from verts vs the three pole axes. Leg verts sit on the + # axes and are skipped; remaining shell verts in the pot band must + # clear the pole radius. + feet = [] + for i in range(3): + ang = i * (2.0 * math.pi / 3.0) + math.radians(18.0) + feet.append( + Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016)) + ) + apex = Vector((0.0, 0.0, APEX_Z)) + z0 = POT_Z0 - 0.02 + z1 = POT_Z0 + POT_H + 0.03 + min_c = None + for v in mesh.vertices: + p = Vector(v.co) + if p.z < z0 or p.z > z1: + continue + if math.hypot(p.x, p.y) < 0.06: + continue + nearest = None + on_pole = False + for foot in feet: + ab = apex - foot + denom = ab.length_squared + if denom < 1e-12: + continue + t = max(0.0, min(1.0, (p - foot).dot(ab) / denom)) + axis = (p - (foot + t * ab)).length + rad = 0.028 * (1.0 - t) + 0.018 * t + gap = axis - rad + if axis < rad + 0.006: + on_pole = True + break + if nearest is None or gap < nearest: + nearest = gap + if on_pole or nearest is None: + continue + if min_c is None or nearest < min_c: + min_c = nearest + return min_c if min_c is not None else -1.0 + + def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: @@ -584,6 +628,14 @@ def check(skip_decimate): size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + clearance = pot_tripod_clearance(low.data) + print(f"measured pot_tripod_clearance={clearance:.4f}") + if clearance < POT_LEG_CLEARANCE_MIN: + return fail( + f"pot clips tripod clearance {clearance:.4f} " + f"< {POT_LEG_CLEARANCE_MIN}", + 3, + ), None, None, None, None, None img, tex = setup_bake_image(low, wood) if img is None: @@ -762,10 +814,10 @@ def light(name, loc, energy, size, col, rot): cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.58, -2.18, 1.22) + cam.location = (1.82, -2.50, 1.40) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.46) + aim.location = (0.0, 0.0, 0.40) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim diff --git a/showcase/iron-cauldron/preview.webp b/showcase/iron-cauldron/preview.webp index 98537b2..86c7d14 100644 Binary files a/showcase/iron-cauldron/preview.webp and b/showcase/iron-cauldron/preview.webp differ