From 87931e73f39becfc998a9a73178e4baa7f544037 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:23:52 -0400 Subject: [PATCH 1/3] docs(showcase): lock orthogonal-member and wall-mount zmin conventions Future pieces inherit axis-aligned boards (exit 19) and wall-sconce plaque bottoms at Z=0 (exit 16), plus finger tips cut from the member section rather than glued cubes. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- showcase/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/showcase/README.md b/showcase/README.md index f9dbeb84..39401186 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -101,6 +101,16 @@ entry in `showcase/gallery.json`, and a rendered still. scatter is stones, not open crates; a per-shell face floor is the budget that catches a cube leftover (exit 19). `--poke-rock` skips the clamp and over-bites; `--float-rocks` seats above the host. +- **Orthogonal members (exit 19).** Boards, planks or arms that should + sit on a world axis assert their AABB secondary extent stays within a + named bound of the member thickness. A closed-form yaw of 0.12 rad + reads as an accident, not weathering. `--yaw-boards` is the + falsifier. Fingerboard tips are a wedge whose base is the board's own + thickness×height, bitten into the board so the base face is inside + it — not a 45° cube of a different size glued on the end. +- **Wall-mount zmin (exit 16).** A wall sconce plants the plaque bottom + at Z=0. Lifting every vert by a hanging height so the asset floats in + its own space is not the same as placing it on a wall in the engine. - **Plumb and real-world size (exit 19).** Assert that the axis of a turned or lofted body is vertical, by comparing the XY centroid of a bottom slab against a top slab — not the exact `zmin` and `zmax` From 27a755bf9320d90c1f193f6685f47f03a7dda88d Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:24:20 -0400 Subject: [PATCH 2/3] fix(showcase): wall-torch visual quality pass Rebuild the sconce from named plate and cup stations so it reads as a wall torch: running-bond ashlar, a round arm into an upward bowl, plaque zmin at 0. Hygiene 15-19 and joint-fit budgets lock the contacts. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- README.md | 2 +- docs/gallery/assets/wall-torch-hero.webp | Bin 10668 -> 27902 bytes docs/gallery/assets/wall-torch-preview.webp | Bin 0 -> 20888 bytes docs/gallery/index.html | 2 +- docs/gallery/wall-torch/index.html | 730 ++++++++++++++------ showcase/gallery.json | 2 +- showcase/wall-torch/README.md | 102 ++- showcase/wall-torch/preview.webp | Bin 9804 -> 27902 bytes showcase/wall-torch/wall_torch.py | 696 +++++++++++++------ 9 files changed, 1066 insertions(+), 468 deletions(-) create mode 100644 docs/gallery/assets/wall-torch-preview.webp diff --git a/README.md b/README.md index bc8fc3d7..929b35a7 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`wooden-bucket`](showcase/wooden-bucket/) — procedural coopered pail with a round bail through the ear rings through the same pipeline. Falsifier `--float-handle` exits 17. -[`wall-torch`](showcase/wall-torch/) — procedural wall-mounted torch sconce with a stone plaque, iron bracket, and flame through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`wall-torch`](showcase/wall-torch/) — procedural wall-mounted torch sconce with a stone plaque, iron bracket, and flame through the same pipeline. Falsifiers `--skip-decimate` (9), `--stray-vert` (15), `--float-arm` (17). [`tavern-stool`](showcase/tavern-stool/) — procedural tavern stool with turned legs, stretchers, and iron ferrules through the same pipeline. Falsifier `--skip-decimate` exits 9. diff --git a/docs/gallery/assets/wall-torch-hero.webp b/docs/gallery/assets/wall-torch-hero.webp index cb700f3c167fc2cca9667117c426135243cd0103..a790e58be7c337d82505a7aac0b4cde3c07ac068 100644 GIT binary patch literal 27902 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

1#zhLA+Qm&jM$a+~r*#io2MT&cs)*Uks5ZoFHA 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 literal 10668 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 S$_B2$6rWR;%v^lX0000YxzqCi diff --git a/docs/gallery/assets/wall-torch-preview.webp b/docs/gallery/assets/wall-torch-preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..8b4c01f87d8de729691935881d2d7ab0d573294e GIT binary patch literal 20888 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

wall-torch

A procedural wall-mounted torch sconce with a dressed stone plaque, iron bracket, wooden haft, and emissive flame through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 848 tris, four materials with 162 stone, 134 metal, and 44 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.384×0.360×0.670 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 1528 tris, four materials with 540 stone, 228 metal and 78 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.304×0.293×0.424 m at zmin 0, LOD ratios in band, convex collider 60 tris, non-empty glTF. Hygiene all zero. Arm-to-cup gap 0.87 mm, plate-to-plaque gap 4 mm, plaque width 0.280 m. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-arm 17; --float-plate 18; --skinny-plaque 19.

View example
diff --git a/docs/gallery/wall-torch/index.html b/docs/gallery/wall-torch/index.html index 8e07e51a..0ebae7b0 100644 --- a/docs/gallery/wall-torch/index.html +++ b/docs/gallery/wall-torch/index.html @@ -253,28 +253,44 @@

wall-torch

wall-torch render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 848 tris, four materials with 162 stone, 134 metal, and 44 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.384×0.360×0.670 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 1528 tris, four materials with 540 stone, 228 metal and 78 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.304×0.293×0.424 m at zmin 0, LOD ratios in band, convex collider 60 tris, non-empty glTF. Hygiene all zero. Arm-to-cup gap 0.87 mm, plate-to-plaque gap 4 mm, plaque width 0.280 m. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-arm 17; --float-plate 18; --skinny-plaque 19.
blender --background --python showcase/wall-torch/wall_torch.py --
-

A showcase piece, not an example. Procedural wall-mounted torch sconce (dressed stone plaque, iron bracket and cup, wooden haft, emissive flame) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

A showcase piece, not an example. Procedural wall-mounted torch sconce (running-bond dressed stone plaque, iron plate and arm from named stations, bowl cup, wooden haft, emissive flame) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

The plaque back sits on the wall plane Y=0 with zmin at 0. The iron arm is a tube from the plate station to the cup wall, not a pair of square bars aimed at a funnel. The bowl opens upward and holds the haft. Hygiene family 15–19: --stray-vert, --lift-z, --float-arm, --float-plate, --skinny-plaque.

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

-

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, procedural-materials-and-shaders, 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).

+

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, procedural-materials-and-shaders, 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). Hygiene combinatorics match examples/mesh-hygiene-audit (copied, not imported).

+

Intended size: 0.28 m plaque width, 0.40 m plaque height, bowl projecting 0.24 m from the wall; outer AABB 0.304 × 0.293 × 0.424 m.

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 | 800–920 | 848 / 848 / 848 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2099 | | Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 162 stone, 134 metal, 44 flame | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.384, 0.360, 0.670) m ± 0.01 | (0.3840, 0.3600, 0.6700), zmin 0.140 | | Collider tris | ≤ 120 | 92 | | Export | written, size > 0 | 69060 / 69060 / 69048 bytes |

-

DECIMATE COLLAPSE triangle counts are not identical across series — 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.

-

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

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 1400–1700 | 1528 / 1528 / 1528 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | | Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 540 stone, 228 metal, 78 flame | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.304, 0.293, 0.424) m ± 0.015 | (0.3040, 0.2930, 0.4240), zmin 0 | | Collider tris | ≤ 180 | 60 | | Export | written, size > 0 | 124608 / 124608 / 124584 bytes |

+

Base triangles rose from 848 to 1528 in the quality pass: one mailbox slab became four courses of running-bond ashlar plus a round arm and a triangulated bowl. Outer AABB tightened from 0.384 × 0.360 × 0.670 m (and zmin 0.140) to 0.304 × 0.293 × 0.424 m at zmin 0.

+

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

+

Hygiene

+

Recomputed from the generated mesh, not asserted about the script.

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Non-manifold edges | 0 | 0 | | Loose verts / edges | 0 / 0 | 0 / 0 | | Doubles merged at 1e-5 | 0 | 0 | | Zero-area faces | 0 | 0 | | N-gons | 0 | 0 | | Coplanar disjoint face pairs | 0 | 0 | | Grounded: zmin | within 1e-4 of 0 | 0.0000 |

+

Joint fit and plaque size

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Arm-to-cup BVH gap | ≤ 0.008 m | 0.00087 | | Plate-to-plaque BVH gap | ≤ 0.008 m | 0.00400 | | Plaque width vs WALL_W | ± 0.04 m | 0.2800 vs 0.280 |

+

Falsifiers

+

Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.

+

| Flag | Budget violated | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band | 9 | | --stray-vert | loose vertex count is 0 | 15 | | --lift-z | bounding box zmin is 0 | 16 | | --float-arm | arm-to-cup BVH gap | 17 | | --float-plate | plate-to-plaque BVH gap | 18 | | --skinny-plaque | plaque width vs WALL_W | 19 |

+

--float-arm shortens the tube to 55 % of the plate-to-cup station vector; the cup stays put. --float-plate moves only the hang plate off the plaque; the arm still reaches the cup so 17 still passes. --skinny-plaque scales the back plaque and courses to 0.55 · WALL_W.

Run

blender --background --python wall_torch.py --
 blender --background --python wall_torch.py -- --skip-decimate
+blender --background --python wall_torch.py -- --stray-vert
+blender --background --python wall_torch.py -- --lift-z
+blender --background --python wall_torch.py -- --float-arm
+blender --background --python wall_torch.py -- --float-plate
+blender --background --python wall_torch.py -- --skinny-plaque
 blender --background --python wall_torch.py -- --output torch.png
-

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

+

Smoke passes no flags.

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 ≠ 4 distinct slots, or stone/metal/flame 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 |

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family.

+

| 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 ≠ 4 distinct slots, or stone/metal/flame 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 | | 15 | Hygiene (--stray-vert lands here) | | 16 | Grounded zmin (--lift-z) | | 17 | Arm-to-cup gap (--float-arm) | | 18 | Plate-to-plaque gap (--float-plate) | | 19 | Plaque width (--skinny-plaque) |

Source

@@ -284,17 +300,20 @@

Source

"""Game-ready wall torch sconce — a showcase piece, not an example.
 
-Asserts budget conformance of a procedural wall-mounted torch after composing
-shipped pipeline pieces: bmesh construction, UVs, four materials, high-to-low
-normal bake, LOD chain, convex collider, Unity glTF export.
+Asserts budget conformance of a procedural wall-mounted torch after
+composing shipped pipeline pieces: bmesh construction, UVs, four
+materials, high-to-low normal bake, LOD chain, convex collider,
+Unity glTF export.
 
-Budgets are declared below and recomputed from the generated result.
-They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
-DECIMATE stage so the LOD-ratio budget fails.
+The plaque back sits on the wall plane Y=0 with zmin at 0. The iron
+arm is a tube between the plate station and the cup station. The
+bowl opens upward and holds the haft. Hygiene family 15–19:
+``--stray-vert``, ``--lift-z``, ``--float-arm``, ``--float-plate``,
+``--skinny-plaque``.
 
-No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
-are not byte-identical across Blender versions — the LOD gate is a
-ratio band, not an exact count.
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle
+counts are not byte-identical across Blender versions — the LOD
+gate is a ratio band, not an exact count.
 
     blender --background --python wall_torch.py --
     blender --background --python wall_torch.py -- --skip-decimate
@@ -310,10 +329,8 @@ 

Source

import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree -# Showcase lives at repo-root/showcase/, not under examples/. The framing -# helper is the repo's only shared import and lives next to the examples; -# resolve the repo root so we do not move gallery_framing.py. _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) ) @@ -321,15 +338,34 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -WALL_W = 0.36 -WALL_D = 0.10 -WALL_H = 0.58 -BBOX_TOL = 0.01 -# Fitted after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (0.384, 0.360, 0.670) - -BASE_TRIS_MIN = 800 -BASE_TRIS_MAX = 920 +WALL_W = 0.28 +WALL_D = 0.070 +WALL_H = 0.40 +CORNICE_H = 0.028 +GROUT = 0.007 +NROWS = 4 +STONE_D = 0.020 +STONE_BITE = 0.004 +PLATE_Z = 0.22 +PLATE_R = 0.055 +PLATE_T = 0.016 +CUP_Y = 0.24 +CUP_Z = 0.20 +CUP_H = 0.052 +CUP_R0 = 0.024 +CUP_R1 = 0.050 +ARM_R = 0.011 +ARM_BITE = 0.012 +HAFT_R = 0.016 +LIFT_Z = 0.05 +FLOAT_ARM = 0.55 +FLOAT_PLATE = 0.08 +SKINNY = 0.55 + +BBOX_TOL = 0.015 +OUTER_SIZE = (0.304, 0.293, 0.424) +BASE_TRIS_MIN = 1400 +BASE_TRIS_MAX = 1700 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -339,7 +375,7 @@

Source

MATERIAL_COUNT = 4 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 120 +COLLIDER_TRIS_MAX = 180 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 STONE_FACES_MIN = 24 @@ -351,6 +387,15 @@

Source

WOOD_IDX = 2 FLAME_IDX = 3 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 0.002 +ZFIGHT_COS = 0.98 +ARM_GAP_MAX = 0.008 +PLATE_GAP_MAX = 0.008 +PLAQUE_W_TOL = 0.04 + def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" @@ -367,7 +412,6 @@

Source

def evaluated_triangle_count(obj): - # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). depsgraph = bpy.context.evaluated_depsgraph_get() eval_obj = obj.evaluated_get(depsgraph) eval_mesh = eval_obj.to_mesh() @@ -392,29 +436,11 @@

Source

return verts -def add_oriented_box(bm, a, b, scale_xy, mat_idx): - a = Vector(a) - b = Vector(b) - delta = b - a - length = delta.length - if length < 1e-8: - return [] - quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) - eul = quat.to_euler("XYZ") - return add_box( - bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), - ) - - def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=segments > 4, segments=segments, radius1=radius1, radius2=radius2, @@ -431,38 +457,25 @@

Source

return verts -def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler) - - -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 12 - n_minor = 6 - rings = [] - for i in range(n_major): - u = i * (2.0 * math.pi / n_major) - ring = [] - for j in range(n_minor): - v = j * (2.0 * math.pi / n_minor) - x = (major + minor * math.cos(v)) * math.cos(u) - y = (major + minor * math.cos(v)) * math.sin(u) - z = minor * math.sin(v) - ring.append(bm.verts.new((x, y, z))) - rings.append(ring) - for i in range(n_major): - i2 = (i + 1) % n_major - for j in range(n_minor): - j2 = (j + 1) % n_minor - face = bm.faces.new( - (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2]) - ) - face.material_index = mat_idx - verts = [v for ring in rings for v in ring] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - return verts +def add_cyl_between(bm, a, b, radius, segments, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_cone( + bm, + (a + b) * 0.5, + radius, + radius, + length, + segments, + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) def pack_uvs(bm, margin=0.08): @@ -508,138 +521,133 @@

Source

) -def build_torch_mesh(name, bevel_offset, bevel_segments): +def build_torch_mesh( + name, + bevel_offset, + bevel_segments, + float_arm=False, + float_plate=False, + skinny_plaque=False, +): bm = bmesh.new() - metal_faces = set() - flame_faces = set() try: - # One dressed plaque — a tile grid reads as bathroom masonry. - add_box( - bm, - (0.0, 0.0, WALL_H / 2.0), - (WALL_W, WALL_D, WALL_H), - STONE_IDX, - ) + plaque_w = WALL_W * SKINNY if skinny_plaque else WALL_W add_box( bm, - (0.0, WALL_D / 2.0 + 0.010, WALL_H / 2.0), - (WALL_W - 0.048, 0.022, WALL_H - 0.070), + (0.0, WALL_D / 2.0, WALL_H / 2.0), + (plaque_w, WALL_D, WALL_H), STONE_IDX, ) add_box( bm, - (0.0, 0.0, WALL_H + 0.014), - (WALL_W + 0.024, WALL_D + 0.024, 0.030), + (0.0, WALL_D / 2.0 + 0.006, WALL_H + CORNICE_H / 2.0 - 0.004), + (plaque_w + 0.024, WALL_D + 0.018, CORNICE_H), STONE_IDX, ) - plate_y = WALL_D / 2.0 + 0.024 - plate_z = 0.30 - before = set(bm.faces) - add_box(bm, (0.0, plate_y, plate_z), (0.14, 0.018, 0.20), METAL_IDX) - add_box(bm, (0.0, plate_y, plate_z + 0.078), (0.18, 0.014, 0.028), METAL_IDX) - add_box(bm, (0.0, plate_y, plate_z - 0.078), (0.18, 0.014, 0.028), METAL_IDX) - add_rim( - bm, - (0.0, plate_y + 0.020, plate_z + 0.016), - 0.048, - 0.009, - METAL_IDX, - euler=(math.radians(90.0), 0.0, 0.0), - ) - arm_y0 = plate_y + 0.012 - arm_y1 = plate_y + 0.15 - cup_z = 0.26 - add_oriented_box( - bm, - (0.032, arm_y0, plate_z - 0.018), - (0.020, arm_y1, cup_z + 0.036), - (0.018, 0.018), - METAL_IDX, - ) - add_oriented_box( - bm, - (-0.032, arm_y0, plate_z - 0.018), - (-0.020, arm_y1, cup_z + 0.036), - (0.018, 0.018), - METAL_IDX, - ) + course_h = (WALL_H - GROUT * (NROWS + 1)) / NROWS + face_y = WALL_D - STONE_BITE + STONE_D / 2.0 + inner = plaque_w - 2.0 * GROUT + for row in range(NROWS): + zc = GROUT + row * (course_h + GROUT) + course_h / 2.0 + fracs = (0.58, 0.42) if row % 2 == 0 else (0.40, 0.60) + x = -plaque_w / 2.0 + GROUT + for frac in fracs: + w = inner * frac - GROUT * 0.5 + cx = x + w / 2.0 + add_box( + bm, + (cx, face_y, zc), + (max(w, 0.04), STONE_D, course_h - 0.001), + STONE_IDX, + ) + x += w + GROUT + + plate_y = WALL_D - 0.004 + PLATE_T / 2.0 + if float_plate: + plate_y += FLOAT_PLATE add_cone( bm, - (0.0, arm_y1 + 0.012, cup_z), - 0.062, - 0.034, - 0.062, - 14, - METAL_IDX, - ) - add_cylinder( - bm, - (0.0, arm_y1 + 0.012, cup_z - 0.042), - 0.054, - 0.014, - 14, + (0.0, plate_y, PLATE_Z), + PLATE_R, + PLATE_R, + PLATE_T, + 12, METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), ) - metal_faces.update(set(bm.faces) - before) + for sx, sz in ((-0.032, 0.028), (0.032, 0.028), (-0.032, -0.028), (0.032, -0.028)): + add_cone( + bm, + (sx, plate_y + PLATE_T / 2.0 + 0.004, PLATE_Z + sz), + 0.006, + 0.006, + 0.010, + 8, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) - stick_z0 = cup_z - 0.018 - stick_z1 = 0.50 - add_cylinder( + plate_st = Vector((0.0, plate_y, PLATE_Z)) + cup_st = Vector((0.0, CUP_Y - CUP_R1 + ARM_BITE, CUP_Z)) + arm_end = cup_st + if float_arm: + arm_end = plate_st + (cup_st - plate_st) * FLOAT_ARM + add_cyl_between(bm, plate_st, arm_end, ARM_R, 8, METAL_IDX) + + add_cone( bm, - (0.0, arm_y1 + 0.012, (stick_z0 + stick_z1) / 2.0), - 0.026, - stick_z1 - stick_z0, + (0.0, CUP_Y, CUP_Z), + CUP_R0, + CUP_R1, + CUP_H, 12, - WOOD_IDX, + METAL_IDX, ) add_cone( bm, - (0.0, arm_y1 + 0.012, 0.46), - 0.050, - 0.030, - 0.12, + (0.0, CUP_Y, CUP_Z - CUP_H / 2.0 - 0.006), + CUP_R0 + 0.004, + CUP_R0 + 0.004, + 0.010, 12, - WOOD_IDX, + METAL_IDX, ) - before = set(bm.faces) - flame_y = arm_y1 + 0.012 - add_cone(bm, (0.0, flame_y, 0.56), 0.048, 0.006, 0.22, 10, FLAME_IDX) + cup_rim = CUP_Z + CUP_H / 2.0 + haft_z0 = CUP_Z - CUP_H / 2.0 + 0.006 + haft_z1 = cup_rim + 0.026 add_cone( bm, - (0.018, flame_y - 0.010, 0.545), - 0.030, - 0.004, - 0.16, - 9, - FLAME_IDX, - euler=(0.0, math.radians(16.0), 0.0), + (0.0, CUP_Y, (haft_z0 + haft_z1) / 2.0), + HAFT_R, + HAFT_R, + haft_z1 - haft_z0, + 10, + WOOD_IDX, ) + + add_cone(bm, (0.0, CUP_Y, cup_rim + 0.038), 0.034, 0.004, 0.078, 10, FLAME_IDX) add_cone( bm, - (-0.016, flame_y + 0.012, 0.54), - 0.028, - 0.004, - 0.15, - 9, + (0.012, CUP_Y - 0.008, cup_rim + 0.028), + 0.022, + 0.003, + 0.058, + 8, FLAME_IDX, - euler=(math.radians(-12.0), math.radians(-14.0), 0.0), + euler=(0.0, math.radians(14.0), 0.0), ) add_cone( bm, - (0.006, flame_y + 0.004, 0.58), + (-0.010, CUP_Y + 0.006, cup_rim + 0.024), 0.018, 0.003, - 0.12, + 0.050, 8, FLAME_IDX, - euler=(math.radians(8.0), math.radians(6.0), 0.0), + euler=(math.radians(-10.0), math.radians(-12.0), 0.0), ) - flame_faces.update(set(bm.faces) - before) - - for v in bm.verts: - v.co.z += 0.14 if bevel_offset > 0.0: stone_edges = [ @@ -647,7 +655,7 @@

Source

if any(f.material_index == STONE_IDX for f in e.link_faces) ] if stone_edges: - bmesh.ops.bevel( + ret = bmesh.ops.bevel( bm, geom=stone_edges, offset=bevel_offset, @@ -656,25 +664,25 @@

Source

affect="EDGES", clamp_overlap=True, ) + for f in ret.get("faces") or []: + f.material_index = STONE_IDX + + zs = [v.co.z for v in bm.verts] + zmin = min(zs) + for v in bm.verts: + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: - face.smooth = face.material_index != STONE_IDX - for edge in bm.edges: - edge.smooth = True - if edge.is_manifold and len(edge.link_faces) == 2: - if edge.calc_face_angle() > math.radians(35.0): - edge.smooth = False - for f in metal_faces: - if f.is_valid: - f.material_index = METAL_IDX - for f in flame_faces: - if f.is_valid: - f.material_index = FLAME_IDX + face.smooth = face.material_index in (METAL_IDX, FLAME_IDX) me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() + for poly in me.polygons: + poly.use_smooth = poly.material_index in (METAL_IDX, FLAME_IDX) finally: bm.free() obj = bpy.data.objects.new(name, me) @@ -682,10 +690,20 @@

Source

return obj -def principled(name, color, metallic, roughness, emission=0.0, emission_color=None): +def principled( + name, + color, + metallic, + roughness, + emission=0.0, + emission_color=None, + noise_scale=0.0, + wear=None, +): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness @@ -696,28 +714,36 @@

Source

bsdf.inputs["Emission Strength"].default_value = emission elif "Emission" in bsdf.inputs: bsdf.inputs["Emission"].default_value = ecol + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 6.0 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) return mat def assign_slots(obj, stone, metal, wood, flame): mats = obj.data.materials - slots = (stone, metal, wood, flame) - if len(mats) == 0: - for s in slots: - mats.append(s) - return - for i, s in enumerate(slots): + wanted = (stone, metal, wood, flame) + for i, mat in enumerate(wanted): if i < len(mats): - mats[i] = s + mats[i] = mat else: - mats.append(s) + mats.append(mat) def world_bbox(obj): - corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] - xs = [c.x for c in corners] - ys = [c.y for c in corners] - zs = [c.z for c in corners] + mat = obj.matrix_world + pts = [mat @ v.co for v in obj.data.vertices] + xs = [p.x for p in pts] + ys = [p.y for p in pts] + zs = [p.z for p in pts] return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) @@ -759,7 +785,6 @@

Source

def convex_hull_collider(obj, name): - # Duplicated from snippets/convex_hull_collider.py (not a package). mesh = bpy.data.meshes.new(name) bm = bmesh.new() try: @@ -781,6 +806,180 @@

Source

return collider +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, + "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, + "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf, + } + + +def zfight_pairs(me): + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +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: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), + max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), + ) + + +def mat_of(me, group): + member = set(group) + for poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def shell_bvh_gap(me, ga, gb): + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + keep_a, keep_b = set(ga), set(gb) + drop_a = [f for f in bm_a.faces if not all(v.index in keep_a for v in f.verts)] + drop_b = [f for f in bm_b.faces if not all(v.index in keep_b for v in f.verts)] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for v in bm_a.verts: + hit = tree.find_nearest(v.co) + if hit[0] is not None: + best = min(best, hit[3]) + for f in bm_a.faces: + hit = tree.find_nearest(f.calc_center_median()) + if hit[0] is not None: + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, CUP_Y, PLATE_Z)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + +def joint_audit(me): + groups = shells(me) + plaques = [] + plates = [] + cups = [] + arms = [] + for g in groups: + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + mat = mat_of(me, g) + if mat == STONE_IDX and dz > 0.20 and dx > 0.12: + plaques.append((g, a)) + elif mat == METAL_IDX and dy <= 0.04 and dx >= 0.08 and dz >= 0.08: + plates.append((g, a)) + elif mat == METAL_IDX and a[4] > CUP_Y - 0.05 and dx > 0.05 and dz < 0.14: + cups.append((g, a)) + elif mat == METAL_IDX and dy > dx and dy > dz: + arms.append((g, a)) + arm_gap = 1e9 + for ag, _ in arms: + for cg, _ in cups: + arm_gap = min(arm_gap, shell_bvh_gap(me, ag, cg)) + plate_gap = 1e9 + for pg, _ in plates: + for qg, _ in plaques: + plate_gap = min(plate_gap, shell_bvh_gap(me, pg, qg)) + plaque_w = max((a[3] - a[0] for _, a in plaques), default=0.0) + return { + "plaques": len(plaques), + "plates": len(plates), + "cups": len(cups), + "arms": len(arms), + "arm_gap": arm_gap if arms and cups else 1e9, + "plate_gap": plate_gap if plates and plaques else 1e9, + "plaque_w": plaque_w, + } + + def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None @@ -835,13 +1034,34 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + float_arm=False, + float_plate=False, + skinny_plaque=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2) - high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4) - stone = principled("TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72) - metal = principled("TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38) - wood = principled("TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64) + kw = dict( + float_arm=float_arm, + float_plate=float_plate, + skinny_plaque=skinny_plaque, + ) + low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2, **kw) + high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4, **kw) + stone = principled( + "TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72, + noise_scale=12.0, wear=(0.28, 0.26, 0.22, 1.0), + ) + metal = principled( + "TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38, + noise_scale=18.0, wear=(0.22, 0.16, 0.10, 1.0), + ) + wood = principled( + "TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64, + noise_scale=14.0, wear=(0.12, 0.06, 0.03, 1.0), + ) flame = principled( "TorchFlame", (1.0, 0.28, 0.04, 1.0), @@ -853,6 +1073,13 @@

Source

assign_slots(low, stone, metal, wood, flame) assign_slots(high, stone, metal, wood, flame) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + if low.data is None or len(low.data.polygons) < 6: return fail("torch mesh did not build", 3), None, None, None, None, None @@ -883,7 +1110,7 @@

Source

r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 - collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1, **kw) collider = convex_hull_collider(collider_src, "TorchCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -897,9 +1124,11 @@

Source

export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + jnt = joint_audit(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -916,6 +1145,17 @@

Source

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 plaques={jnt['plaques']} plates={jnt['plates']} " + f"cups={jnt['cups']} arms={jnt['arms']} " + f"arm_gap={jnt['arm_gap']:.5f} plate_gap={jnt['plate_gap']:.5f} " + f"plaque_w={jnt['plaque_w']:.4f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -952,6 +1192,24 @@

Source

f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7, ), None, None, None, None, None + if bb[2] > ZMIN_EPS: + return fail(f"grounded zmin={bb[2]:.5f}", 16), None, None, None, None, None + if jnt["arms"] < 1 or jnt["cups"] < 1 or jnt["arm_gap"] > ARM_GAP_MAX: + return fail( + f"arm-cup gap {jnt['arm_gap']:.5f} arms={jnt['arms']} cups={jnt['cups']}", + 17, + ), None, None, None, None, None + if jnt["plates"] < 1 or jnt["plaques"] < 1 or jnt["plate_gap"] > PLATE_GAP_MAX: + return fail( + f"plate-plaque gap {jnt['plate_gap']:.5f} plates={jnt['plates']} " + f"plaques={jnt['plaques']}", + 18, + ), None, None, None, None, None + if abs(jnt["plaque_w"] - WALL_W) > PLAQUE_W_TOL: + return fail( + f"plaque width {jnt['plaque_w']:.4f} off {WALL_W}", + 19, + ), None, None, None, None, None if ( abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL @@ -985,6 +1243,16 @@

Source

), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}", + 15, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -1005,8 +1273,7 @@

Source

ob.hide_render = True ob.hide_viewport = True - # Sconce is built on +Y; camera sits in -Y, so yaw 180 so the flame faces us. - low.rotation_euler.z = math.radians(142.0) + low.rotation_euler.z = math.radians(128.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -1048,16 +1315,15 @@

Source

light("Key", (-3.6, -5.0, 5.4), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) light("Fill", (5.0, -3.4, 2.4), 42.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.2, 4.0, 3.8), 560.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) - # Small warm key on the flame so emission reads in EEVEE stills. - light("FlameKick", (0.4, 1.2, 1.1), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160)) + light("FlameKick", (0.4, 1.2, 0.8), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160)) cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 50.0 + cam_data.lens = 55.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.10, -1.70, 0.64) + cam.location = (-0.95, -1.15, 0.36) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.06, 0.48) + aim.location = (0.02, 0.06, 0.21) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1100,14 +1366,22 @@

Source

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", - help="falsification: skip the LOD DECIMATE stage", - ) + 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-arm", action="store_true") + p.add_argument("--float-plate", action="store_true") + p.add_argument("--skinny-plaque", action="store_true") args = p.parse_args(argv) - code, low, _high, stone, tex, _col = check(args.skip_decimate) + code, low, _high, stone, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_arm=args.float_arm, + float_plate=args.float_plate, + skinny_plaque=args.skinny_plaque, + ) if code: return code if args.output: diff --git a/showcase/gallery.json b/showcase/gallery.json index 158a1a46..6f06cb0e 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -261,7 +261,7 @@ "name": "wall-torch", "dir": "showcase/wall-torch", "teaches": "A procedural wall-mounted torch sconce with a dressed stone plaque, iron bracket, wooden haft, and emissive flame through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 848 tris, four materials with 162 stone, 134 metal, and 44 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.384×0.360×0.670 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 1528 tris, four materials with 540 stone, 228 metal and 78 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.304×0.293×0.424 m at zmin 0, LOD ratios in band, convex collider 60 tris, non-empty glTF. Hygiene all zero. Arm-to-cup gap 0.87 mm, plate-to-plaque gap 4 mm, plaque width 0.280 m. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-arm 17; --float-plate 18; --skinny-plaque 19.", "hero": "docs/gallery/assets/wall-torch-hero.webp", "preview": "showcase/wall-torch/preview.webp", "tags": [ diff --git a/showcase/wall-torch/README.md b/showcase/wall-torch/README.md index 44a11a12..1c2193a7 100644 --- a/showcase/wall-torch/README.md +++ b/showcase/wall-torch/README.md @@ -1,9 +1,16 @@ # Wall torch A showcase piece, not an example. Procedural wall-mounted torch sconce -(dressed stone plaque, iron bracket and cup, wooden haft, emissive flame) -then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal -bake, LOD chain, convex collider, Unity glTF export. +(running-bond dressed stone plaque, iron plate and arm from named +stations, bowl cup, wooden haft, emissive flame) then the shipped +pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, +convex collider, Unity glTF export. + +The plaque back sits on the wall plane Y=0 with zmin at 0. The iron +arm is a tube from the plate station to the cup wall, not a pair of +square bars aimed at a funnel. The bowl opens upward and holds the +haft. Hygiene family 15–19: `--stray-vert`, `--lift-z`, `--float-arm`, +`--float-plate`, `--skinny-plaque`. It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -14,6 +21,11 @@ witness an API contract. "It rendered without error" is not a check. `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). +Hygiene combinatorics match `examples/mesh-hygiene-audit` (copied, not +imported). + +Intended size: 0.28 m plaque width, 0.40 m plaque height, bowl +projecting 0.24 m from the wall; outer AABB 0.304 × 0.293 × 0.424 m. ## Budgets @@ -22,38 +34,87 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 800–920 | 848 / 848 / 848 | +| Base triangles | 1400–1700 | 1528 / 1528 / 1528 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2099 | -| Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 162 stone, 134 metal, 44 flame | +| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | +| Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 540 stone, 228 metal, 78 flame | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (0.384, 0.360, 0.670) m ± 0.01 | (0.3840, 0.3600, 0.6700), zmin 0.140 | -| Collider tris | ≤ 120 | 92 | -| Export | written, size > 0 | 69060 / 69060 / 69048 bytes | +| Outer AABB | (0.304, 0.293, 0.424) m ± 0.015 | (0.3040, 0.2930, 0.4240), zmin 0 | +| Collider tris | ≤ 180 | 60 | +| Export | written, size > 0 | 124608 / 124608 / 124584 bytes | + +Base triangles rose from **848 to 1528** in the quality pass: one +mailbox slab became four courses of running-bond ashlar plus a round +arm and a triangulated bowl. Outer AABB tightened from 0.384 × 0.360 × +0.670 m (and zmin 0.140) to 0.304 × 0.293 × 0.424 m at zmin 0. + +DECIMATE COLLAPSE triangle counts are **not** identical across series. +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 +24 B on 5.2.1 (glTF serializer), not a gated axis. + +### Hygiene + +Recomputed from the generated mesh, not asserted about the script. + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Non-manifold edges | 0 | 0 | +| Loose verts / edges | 0 / 0 | 0 / 0 | +| Doubles merged at 1e-5 | 0 | 0 | +| Zero-area faces | 0 | 0 | +| N-gons | 0 | 0 | +| Coplanar disjoint face pairs | 0 | 0 | +| Grounded: `zmin` | within 1e-4 of 0 | 0.0000 | + +### Joint fit and plaque size + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Arm-to-cup BVH gap | ≤ 0.008 m | 0.00087 | +| Plate-to-plaque BVH gap | ≤ 0.008 m | 0.00400 | +| Plaque width vs `WALL_W` | ± 0.04 m | 0.2800 vs 0.280 | -DECIMATE COLLAPSE triangle counts are **not** identical across series — -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. +### Falsifiers + +Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 +and returned the same code on each. + +| Flag | Budget violated | Exit | +| --- | --- | --- | +| `--skip-decimate` | LOD1 ratio band | 9 | +| `--stray-vert` | loose vertex count is 0 | 15 | +| `--lift-z` | bounding box `zmin` is 0 | 16 | +| `--float-arm` | arm-to-cup BVH gap | 17 | +| `--float-plate` | plate-to-plaque BVH gap | 18 | +| `--skinny-plaque` | plaque width vs `WALL_W` | 19 | -`--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. +`--float-arm` shortens the tube to 55 % of the plate-to-cup station +vector; the cup stays put. `--float-plate` moves only the hang plate +off the plaque; the arm still reaches the cup so 17 still passes. +`--skinny-plaque` scales the back plaque and courses to 0.55 · `WALL_W`. ## Run ```bash blender --background --python wall_torch.py -- blender --background --python wall_torch.py -- --skip-decimate +blender --background --python wall_torch.py -- --stray-vert +blender --background --python wall_torch.py -- --lift-z +blender --background --python wall_torch.py -- --float-arm +blender --background --python wall_torch.py -- --float-plate +blender --background --python wall_torch.py -- --skinny-plaque blender --background --python wall_torch.py -- --output torch.png ``` -Smoke does not pass `--output` or `--skip-decimate`. +Smoke passes no flags. ## Exit codes File-local. `9` is a valid check code. `10` is reserved for -`gallery_framing.check_framing` on the `--output` path. +`gallery_framing.check_framing` on the `--output` path. `15`–`19` are the +hygiene and joint-fit family. | Code | Meaning | | --- | --- | @@ -72,3 +133,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 (`--stray-vert` lands here) | +| 16 | Grounded zmin (`--lift-z`) | +| 17 | Arm-to-cup gap (`--float-arm`) | +| 18 | Plate-to-plaque gap (`--float-plate`) | +| 19 | Plaque width (`--skinny-plaque`) | diff --git a/showcase/wall-torch/preview.webp b/showcase/wall-torch/preview.webp index 5e0d8556b724998d2bfea1811d20a1914bc51cf2..a790e58be7c337d82505a7aac0b4cde3c07ac068 100644 GIT binary patch literal 27902 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

1#zhLA+Qm&jM$a+~r*#io2MT&cs)*Uks5ZoFHA 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 literal 9804 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 diff --git a/showcase/wall-torch/wall_torch.py b/showcase/wall-torch/wall_torch.py index 6ba38108..da5b4cf3 100644 --- a/showcase/wall-torch/wall_torch.py +++ b/showcase/wall-torch/wall_torch.py @@ -1,16 +1,19 @@ """Game-ready wall torch sconce — a showcase piece, not an example. -Asserts budget conformance of a procedural wall-mounted torch after composing -shipped pipeline pieces: bmesh construction, UVs, four materials, high-to-low -normal bake, LOD chain, convex collider, Unity glTF export. +Asserts budget conformance of a procedural wall-mounted torch after +composing shipped pipeline pieces: bmesh construction, UVs, four +materials, high-to-low normal bake, LOD chain, convex collider, +Unity glTF export. -Budgets are declared below and recomputed from the generated result. -They are not API-contract witnesses. ``--skip-decimate`` skips the LOD -DECIMATE stage so the LOD-ratio budget fails. +The plaque back sits on the wall plane Y=0 with zmin at 0. The iron +arm is a tube between the plate station and the cup station. The +bowl opens upward and holds the haft. Hygiene family 15–19: +``--stray-vert``, ``--lift-z``, ``--float-arm``, ``--float-plate``, +``--skinny-plaque``. -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts -are not byte-identical across Blender versions — the LOD gate is a -ratio band, not an exact count. +No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle +counts are not byte-identical across Blender versions — the LOD +gate is a ratio band, not an exact count. blender --background --python wall_torch.py -- blender --background --python wall_torch.py -- --skip-decimate @@ -26,10 +29,8 @@ import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree -# Showcase lives at repo-root/showcase/, not under examples/. The framing -# helper is the repo's only shared import and lives next to the examples; -# resolve the repo root so we do not move gallery_framing.py. _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) ) @@ -37,15 +38,34 @@ sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -WALL_W = 0.36 -WALL_D = 0.10 -WALL_H = 0.58 -BBOX_TOL = 0.01 -# Fitted after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (0.384, 0.360, 0.670) - -BASE_TRIS_MIN = 800 -BASE_TRIS_MAX = 920 +WALL_W = 0.28 +WALL_D = 0.070 +WALL_H = 0.40 +CORNICE_H = 0.028 +GROUT = 0.007 +NROWS = 4 +STONE_D = 0.020 +STONE_BITE = 0.004 +PLATE_Z = 0.22 +PLATE_R = 0.055 +PLATE_T = 0.016 +CUP_Y = 0.24 +CUP_Z = 0.20 +CUP_H = 0.052 +CUP_R0 = 0.024 +CUP_R1 = 0.050 +ARM_R = 0.011 +ARM_BITE = 0.012 +HAFT_R = 0.016 +LIFT_Z = 0.05 +FLOAT_ARM = 0.55 +FLOAT_PLATE = 0.08 +SKINNY = 0.55 + +BBOX_TOL = 0.015 +OUTER_SIZE = (0.304, 0.293, 0.424) +BASE_TRIS_MIN = 1400 +BASE_TRIS_MAX = 1700 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -55,7 +75,7 @@ MATERIAL_COUNT = 4 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 120 +COLLIDER_TRIS_MAX = 180 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 STONE_FACES_MIN = 24 @@ -67,6 +87,15 @@ WOOD_IDX = 2 FLAME_IDX = 3 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 0.002 +ZFIGHT_COS = 0.98 +ARM_GAP_MAX = 0.008 +PLATE_GAP_MAX = 0.008 +PLAQUE_W_TOL = 0.04 + def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" @@ -83,7 +112,6 @@ def triangle_count(mesh): def evaluated_triangle_count(obj): - # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). depsgraph = bpy.context.evaluated_depsgraph_get() eval_obj = obj.evaluated_get(depsgraph) eval_mesh = eval_obj.to_mesh() @@ -108,29 +136,11 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): return verts -def add_oriented_box(bm, a, b, scale_xy, mat_idx): - a = Vector(a) - b = Vector(b) - delta = b - a - length = delta.length - if length < 1e-8: - return [] - quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) - eul = quat.to_euler("XYZ") - return add_box( - bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), - ) - - def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=segments > 4, segments=segments, radius1=radius1, radius2=radius2, @@ -147,38 +157,25 @@ def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0. return verts -def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler) - - -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 12 - n_minor = 6 - rings = [] - for i in range(n_major): - u = i * (2.0 * math.pi / n_major) - ring = [] - for j in range(n_minor): - v = j * (2.0 * math.pi / n_minor) - x = (major + minor * math.cos(v)) * math.cos(u) - y = (major + minor * math.cos(v)) * math.sin(u) - z = minor * math.sin(v) - ring.append(bm.verts.new((x, y, z))) - rings.append(ring) - for i in range(n_major): - i2 = (i + 1) % n_major - for j in range(n_minor): - j2 = (j + 1) % n_minor - face = bm.faces.new( - (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2]) - ) - face.material_index = mat_idx - verts = [v for ring in rings for v in ring] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - return verts +def add_cyl_between(bm, a, b, radius, segments, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_cone( + bm, + (a + b) * 0.5, + radius, + radius, + length, + segments, + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) def pack_uvs(bm, margin=0.08): @@ -224,138 +221,133 @@ def pack_uvs(bm, margin=0.08): ) -def build_torch_mesh(name, bevel_offset, bevel_segments): +def build_torch_mesh( + name, + bevel_offset, + bevel_segments, + float_arm=False, + float_plate=False, + skinny_plaque=False, +): bm = bmesh.new() - metal_faces = set() - flame_faces = set() try: - # One dressed plaque — a tile grid reads as bathroom masonry. - add_box( - bm, - (0.0, 0.0, WALL_H / 2.0), - (WALL_W, WALL_D, WALL_H), - STONE_IDX, - ) + plaque_w = WALL_W * SKINNY if skinny_plaque else WALL_W add_box( bm, - (0.0, WALL_D / 2.0 + 0.010, WALL_H / 2.0), - (WALL_W - 0.048, 0.022, WALL_H - 0.070), + (0.0, WALL_D / 2.0, WALL_H / 2.0), + (plaque_w, WALL_D, WALL_H), STONE_IDX, ) add_box( bm, - (0.0, 0.0, WALL_H + 0.014), - (WALL_W + 0.024, WALL_D + 0.024, 0.030), + (0.0, WALL_D / 2.0 + 0.006, WALL_H + CORNICE_H / 2.0 - 0.004), + (plaque_w + 0.024, WALL_D + 0.018, CORNICE_H), STONE_IDX, ) - plate_y = WALL_D / 2.0 + 0.024 - plate_z = 0.30 - before = set(bm.faces) - add_box(bm, (0.0, plate_y, plate_z), (0.14, 0.018, 0.20), METAL_IDX) - add_box(bm, (0.0, plate_y, plate_z + 0.078), (0.18, 0.014, 0.028), METAL_IDX) - add_box(bm, (0.0, plate_y, plate_z - 0.078), (0.18, 0.014, 0.028), METAL_IDX) - add_rim( - bm, - (0.0, plate_y + 0.020, plate_z + 0.016), - 0.048, - 0.009, - METAL_IDX, - euler=(math.radians(90.0), 0.0, 0.0), - ) - arm_y0 = plate_y + 0.012 - arm_y1 = plate_y + 0.15 - cup_z = 0.26 - add_oriented_box( - bm, - (0.032, arm_y0, plate_z - 0.018), - (0.020, arm_y1, cup_z + 0.036), - (0.018, 0.018), - METAL_IDX, - ) - add_oriented_box( - bm, - (-0.032, arm_y0, plate_z - 0.018), - (-0.020, arm_y1, cup_z + 0.036), - (0.018, 0.018), - METAL_IDX, - ) + course_h = (WALL_H - GROUT * (NROWS + 1)) / NROWS + face_y = WALL_D - STONE_BITE + STONE_D / 2.0 + inner = plaque_w - 2.0 * GROUT + for row in range(NROWS): + zc = GROUT + row * (course_h + GROUT) + course_h / 2.0 + fracs = (0.58, 0.42) if row % 2 == 0 else (0.40, 0.60) + x = -plaque_w / 2.0 + GROUT + for frac in fracs: + w = inner * frac - GROUT * 0.5 + cx = x + w / 2.0 + add_box( + bm, + (cx, face_y, zc), + (max(w, 0.04), STONE_D, course_h - 0.001), + STONE_IDX, + ) + x += w + GROUT + + plate_y = WALL_D - 0.004 + PLATE_T / 2.0 + if float_plate: + plate_y += FLOAT_PLATE add_cone( bm, - (0.0, arm_y1 + 0.012, cup_z), - 0.062, - 0.034, - 0.062, - 14, - METAL_IDX, - ) - add_cylinder( - bm, - (0.0, arm_y1 + 0.012, cup_z - 0.042), - 0.054, - 0.014, - 14, + (0.0, plate_y, PLATE_Z), + PLATE_R, + PLATE_R, + PLATE_T, + 12, METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), ) - metal_faces.update(set(bm.faces) - before) + for sx, sz in ((-0.032, 0.028), (0.032, 0.028), (-0.032, -0.028), (0.032, -0.028)): + add_cone( + bm, + (sx, plate_y + PLATE_T / 2.0 + 0.004, PLATE_Z + sz), + 0.006, + 0.006, + 0.010, + 8, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + + plate_st = Vector((0.0, plate_y, PLATE_Z)) + cup_st = Vector((0.0, CUP_Y - CUP_R1 + ARM_BITE, CUP_Z)) + arm_end = cup_st + if float_arm: + arm_end = plate_st + (cup_st - plate_st) * FLOAT_ARM + add_cyl_between(bm, plate_st, arm_end, ARM_R, 8, METAL_IDX) - stick_z0 = cup_z - 0.018 - stick_z1 = 0.50 - add_cylinder( + add_cone( bm, - (0.0, arm_y1 + 0.012, (stick_z0 + stick_z1) / 2.0), - 0.026, - stick_z1 - stick_z0, + (0.0, CUP_Y, CUP_Z), + CUP_R0, + CUP_R1, + CUP_H, 12, - WOOD_IDX, + METAL_IDX, ) add_cone( bm, - (0.0, arm_y1 + 0.012, 0.46), - 0.050, - 0.030, - 0.12, + (0.0, CUP_Y, CUP_Z - CUP_H / 2.0 - 0.006), + CUP_R0 + 0.004, + CUP_R0 + 0.004, + 0.010, 12, - WOOD_IDX, + METAL_IDX, ) - before = set(bm.faces) - flame_y = arm_y1 + 0.012 - add_cone(bm, (0.0, flame_y, 0.56), 0.048, 0.006, 0.22, 10, FLAME_IDX) + cup_rim = CUP_Z + CUP_H / 2.0 + haft_z0 = CUP_Z - CUP_H / 2.0 + 0.006 + haft_z1 = cup_rim + 0.026 add_cone( bm, - (0.018, flame_y - 0.010, 0.545), - 0.030, - 0.004, - 0.16, - 9, - FLAME_IDX, - euler=(0.0, math.radians(16.0), 0.0), + (0.0, CUP_Y, (haft_z0 + haft_z1) / 2.0), + HAFT_R, + HAFT_R, + haft_z1 - haft_z0, + 10, + WOOD_IDX, ) + + add_cone(bm, (0.0, CUP_Y, cup_rim + 0.038), 0.034, 0.004, 0.078, 10, FLAME_IDX) add_cone( bm, - (-0.016, flame_y + 0.012, 0.54), - 0.028, - 0.004, - 0.15, - 9, + (0.012, CUP_Y - 0.008, cup_rim + 0.028), + 0.022, + 0.003, + 0.058, + 8, FLAME_IDX, - euler=(math.radians(-12.0), math.radians(-14.0), 0.0), + euler=(0.0, math.radians(14.0), 0.0), ) add_cone( bm, - (0.006, flame_y + 0.004, 0.58), + (-0.010, CUP_Y + 0.006, cup_rim + 0.024), 0.018, 0.003, - 0.12, + 0.050, 8, FLAME_IDX, - euler=(math.radians(8.0), math.radians(6.0), 0.0), + euler=(math.radians(-10.0), math.radians(-12.0), 0.0), ) - flame_faces.update(set(bm.faces) - before) - - for v in bm.verts: - v.co.z += 0.14 if bevel_offset > 0.0: stone_edges = [ @@ -363,7 +355,7 @@ def build_torch_mesh(name, bevel_offset, bevel_segments): if any(f.material_index == STONE_IDX for f in e.link_faces) ] if stone_edges: - bmesh.ops.bevel( + ret = bmesh.ops.bevel( bm, geom=stone_edges, offset=bevel_offset, @@ -372,25 +364,25 @@ def build_torch_mesh(name, bevel_offset, bevel_segments): affect="EDGES", clamp_overlap=True, ) + for f in ret.get("faces") or []: + f.material_index = STONE_IDX + + zs = [v.co.z for v in bm.verts] + zmin = min(zs) + for v in bm.verts: + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: - face.smooth = face.material_index != STONE_IDX - for edge in bm.edges: - edge.smooth = True - if edge.is_manifold and len(edge.link_faces) == 2: - if edge.calc_face_angle() > math.radians(35.0): - edge.smooth = False - for f in metal_faces: - if f.is_valid: - f.material_index = METAL_IDX - for f in flame_faces: - if f.is_valid: - f.material_index = FLAME_IDX + face.smooth = face.material_index in (METAL_IDX, FLAME_IDX) me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() + for poly in me.polygons: + poly.use_smooth = poly.material_index in (METAL_IDX, FLAME_IDX) finally: bm.free() obj = bpy.data.objects.new(name, me) @@ -398,10 +390,20 @@ def build_torch_mesh(name, bevel_offset, bevel_segments): return obj -def principled(name, color, metallic, roughness, emission=0.0, emission_color=None): +def principled( + name, + color, + metallic, + roughness, + emission=0.0, + emission_color=None, + noise_scale=0.0, + wear=None, +): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness @@ -412,28 +414,36 @@ def principled(name, color, metallic, roughness, emission=0.0, emission_color=No bsdf.inputs["Emission Strength"].default_value = emission elif "Emission" in bsdf.inputs: bsdf.inputs["Emission"].default_value = ecol + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 6.0 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) return mat def assign_slots(obj, stone, metal, wood, flame): mats = obj.data.materials - slots = (stone, metal, wood, flame) - if len(mats) == 0: - for s in slots: - mats.append(s) - return - for i, s in enumerate(slots): + wanted = (stone, metal, wood, flame) + for i, mat in enumerate(wanted): if i < len(mats): - mats[i] = s + mats[i] = mat else: - mats.append(s) + mats.append(mat) def world_bbox(obj): - corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] - xs = [c.x for c in corners] - ys = [c.y for c in corners] - zs = [c.z for c in corners] + mat = obj.matrix_world + pts = [mat @ v.co for v in obj.data.vertices] + xs = [p.x for p in pts] + ys = [p.y for p in pts] + zs = [p.z for p in pts] return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) @@ -475,7 +485,6 @@ def make_lod(obj, name, ratio, skip_decimate): def convex_hull_collider(obj, name): - # Duplicated from snippets/convex_hull_collider.py (not a package). mesh = bpy.data.meshes.new(name) bm = bmesh.new() try: @@ -497,6 +506,180 @@ def convex_hull_collider(obj, name): return collider +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, + "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, + "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf, + } + + +def zfight_pairs(me): + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +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: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), + max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), + ) + + +def mat_of(me, group): + member = set(group) + for poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def shell_bvh_gap(me, ga, gb): + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + keep_a, keep_b = set(ga), set(gb) + drop_a = [f for f in bm_a.faces if not all(v.index in keep_a for v in f.verts)] + drop_b = [f for f in bm_b.faces if not all(v.index in keep_b for v in f.verts)] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for v in bm_a.verts: + hit = tree.find_nearest(v.co) + if hit[0] is not None: + best = min(best, hit[3]) + for f in bm_a.faces: + hit = tree.find_nearest(f.calc_center_median()) + if hit[0] is not None: + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, CUP_Y, PLATE_Z)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + +def joint_audit(me): + groups = shells(me) + plaques = [] + plates = [] + cups = [] + arms = [] + for g in groups: + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + mat = mat_of(me, g) + if mat == STONE_IDX and dz > 0.20 and dx > 0.12: + plaques.append((g, a)) + elif mat == METAL_IDX and dy <= 0.04 and dx >= 0.08 and dz >= 0.08: + plates.append((g, a)) + elif mat == METAL_IDX and a[4] > CUP_Y - 0.05 and dx > 0.05 and dz < 0.14: + cups.append((g, a)) + elif mat == METAL_IDX and dy > dx and dy > dz: + arms.append((g, a)) + arm_gap = 1e9 + for ag, _ in arms: + for cg, _ in cups: + arm_gap = min(arm_gap, shell_bvh_gap(me, ag, cg)) + plate_gap = 1e9 + for pg, _ in plates: + for qg, _ in plaques: + plate_gap = min(plate_gap, shell_bvh_gap(me, pg, qg)) + plaque_w = max((a[3] - a[0] for _, a in plaques), default=0.0) + return { + "plaques": len(plaques), + "plates": len(plates), + "cups": len(cups), + "arms": len(arms), + "arm_gap": arm_gap if arms and cups else 1e9, + "plate_gap": plate_gap if plates and plaques else 1e9, + "plaque_w": plaque_w, + } + + def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None @@ -551,13 +734,34 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + float_arm=False, + float_plate=False, + skinny_plaque=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2) - high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4) - stone = principled("TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72) - metal = principled("TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38) - wood = principled("TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64) + kw = dict( + float_arm=float_arm, + float_plate=float_plate, + skinny_plaque=skinny_plaque, + ) + low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2, **kw) + high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4, **kw) + stone = principled( + "TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72, + noise_scale=12.0, wear=(0.28, 0.26, 0.22, 1.0), + ) + metal = principled( + "TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38, + noise_scale=18.0, wear=(0.22, 0.16, 0.10, 1.0), + ) + wood = principled( + "TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64, + noise_scale=14.0, wear=(0.12, 0.06, 0.03, 1.0), + ) flame = principled( "TorchFlame", (1.0, 0.28, 0.04, 1.0), @@ -569,6 +773,13 @@ def check(skip_decimate): assign_slots(low, stone, metal, wood, flame) assign_slots(high, stone, metal, wood, flame) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + if low.data is None or len(low.data.polygons) < 6: return fail("torch mesh did not build", 3), None, None, None, None, None @@ -599,7 +810,7 @@ def check(skip_decimate): r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 - collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1, **kw) collider = convex_hull_collider(collider_src, "TorchCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -613,9 +824,11 @@ def check(skip_decimate): export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + jnt = joint_audit(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -632,6 +845,17 @@ def check(skip_decimate): 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 plaques={jnt['plaques']} plates={jnt['plates']} " + f"cups={jnt['cups']} arms={jnt['arms']} " + f"arm_gap={jnt['arm_gap']:.5f} plate_gap={jnt['plate_gap']:.5f} " + f"plaque_w={jnt['plaque_w']:.4f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -668,6 +892,24 @@ def check(skip_decimate): f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7, ), None, None, None, None, None + if bb[2] > ZMIN_EPS: + return fail(f"grounded zmin={bb[2]:.5f}", 16), None, None, None, None, None + if jnt["arms"] < 1 or jnt["cups"] < 1 or jnt["arm_gap"] > ARM_GAP_MAX: + return fail( + f"arm-cup gap {jnt['arm_gap']:.5f} arms={jnt['arms']} cups={jnt['cups']}", + 17, + ), None, None, None, None, None + if jnt["plates"] < 1 or jnt["plaques"] < 1 or jnt["plate_gap"] > PLATE_GAP_MAX: + return fail( + f"plate-plaque gap {jnt['plate_gap']:.5f} plates={jnt['plates']} " + f"plaques={jnt['plaques']}", + 18, + ), None, None, None, None, None + if abs(jnt["plaque_w"] - WALL_W) > PLAQUE_W_TOL: + return fail( + f"plaque width {jnt['plaque_w']:.4f} off {WALL_W}", + 19, + ), None, None, None, None, None if ( abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL @@ -701,6 +943,16 @@ def check(skip_decimate): ), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}", + 15, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -721,8 +973,7 @@ def render_still(low, stone, tex, path, engine): ob.hide_render = True ob.hide_viewport = True - # Sconce is built on +Y; camera sits in -Y, so yaw 180 so the flame faces us. - low.rotation_euler.z = math.radians(142.0) + low.rotation_euler.z = math.radians(128.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -764,16 +1015,15 @@ def light(name, loc, energy, size, col, rot): light("Key", (-3.6, -5.0, 5.4), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) light("Fill", (5.0, -3.4, 2.4), 42.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.2, 4.0, 3.8), 560.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) - # Small warm key on the flame so emission reads in EEVEE stills. - light("FlameKick", (0.4, 1.2, 1.1), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160)) + light("FlameKick", (0.4, 1.2, 0.8), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160)) cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 50.0 + cam_data.lens = 55.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.10, -1.70, 0.64) + cam.location = (-0.95, -1.15, 0.36) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.06, 0.48) + aim.location = (0.02, 0.06, 0.21) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -816,14 +1066,22 @@ def main(): 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", - help="falsification: skip the LOD DECIMATE stage", - ) + 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-arm", action="store_true") + p.add_argument("--float-plate", action="store_true") + p.add_argument("--skinny-plaque", action="store_true") args = p.parse_args(argv) - code, low, _high, stone, tex, _col = check(args.skip_decimate) + code, low, _high, stone, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_arm=args.float_arm, + float_plate=args.float_plate, + skinny_plaque=args.skinny_plaque, + ) if code: return code if args.output: From 174f05bcbe0ef04ff1dbeac728287979b8b14f51 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:24:47 -0400 Subject: [PATCH 3/3] fix(showcase): signpost visual quality pass Axis-align the boards, cut finger tips from the board section, and wrap the shoe as a closed collar so it reads as a fingerpost. Hygiene 15-19 plus orthogonal-board and named-shoe budgets lock the joints. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- README.md | 2 +- docs/gallery/assets/signpost-hero.webp | Bin 7978 -> 7580 bytes docs/gallery/assets/signpost-preview.webp | Bin 0 -> 7016 bytes docs/gallery/index.html | 2 +- docs/gallery/signpost/index.html | 693 +++++++++++++++------- showcase/gallery.json | 2 +- showcase/signpost/README.md | 102 +++- showcase/signpost/preview.webp | Bin 7108 -> 7580 bytes showcase/signpost/signpost.py | 658 +++++++++++++------- 9 files changed, 1019 insertions(+), 440 deletions(-) create mode 100644 docs/gallery/assets/signpost-preview.webp diff --git a/README.md b/README.md index 929b35a7..392862c5 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`hand-pump`](showcase/hand-pump/) — procedural cast-iron village hand pump through the same pipeline. Falsifier `--skip-decimate` exits 9. -[`signpost`](showcase/signpost/) — procedural timber fingerboard signpost through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`signpost`](showcase/signpost/) — procedural timber fingerboard signpost through the same pipeline. Falsifiers `--skip-decimate` (9), `--stray-vert` (15), `--yaw-boards` (19). [`chopping-block`](showcase/chopping-block/) — procedural hooped timber stump with an embedded axe through the same pipeline. Falsifier `--skip-decimate` exits 9. diff --git a/docs/gallery/assets/signpost-hero.webp b/docs/gallery/assets/signpost-hero.webp index b2727e05e300a151d76e8fe53d4989d6222486b8..c8c1ef4e0ed08f2ba1bcc86da2c5875fd4d0dbbe 100644 GIT binary patch literal 7580 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 GzyJUyjh*BG literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index ad6bc3ee..9a6a8ed0 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -1090,7 +1090,7 @@

hand-pump

signpost

A procedural timber fingerboard signpost through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 504 tris, three materials with 66 metal and 24 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.368×0.201×1.566 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 376 tris, three materials with 108 metal, 22 board and 60 wood faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.260×0.142×1.551 m at zmin 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD1/LOD2), convex collider 52 tris, non-empty glTF. Hygiene all zero. Board-to-post gap 5 mm, strap gap 3 mm, board AABB Y 28 mm. --skip-decimate exits 9; --stray-vert 15; --lift-z / --short-shoe 16; --gap-board 17; --float-strap 18; --yaw-boards 19.

View example
diff --git a/docs/gallery/signpost/index.html b/docs/gallery/signpost/index.html index 9ee0871c..5bb08b0e 100644 --- a/docs/gallery/signpost/index.html +++ b/docs/gallery/signpost/index.html @@ -253,28 +253,45 @@

signpost

signpost render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 504 tris, three materials with 66 metal and 24 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.368×0.201×1.566 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 376 tris, three materials with 108 metal, 22 board and 60 wood faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.260×0.142×1.551 m at zmin 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD1/LOD2), convex collider 52 tris, non-empty glTF. Hygiene all zero. Board-to-post gap 5 mm, strap gap 3 mm, board AABB Y 28 mm. --skip-decimate exits 9; --stray-vert 15; --lift-z / --short-shoe 16; --gap-board 17; --float-strap 18; --yaw-boards 19.
blender --background --python showcase/signpost/signpost.py --
-

A showcase piece, not an example. Procedural timber fingerboard signpost (square post, collar and pyramidal cap, two painted planks with diamond tips, iron shoe and straps) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

A showcase piece, not an example. Procedural timber fingerboard signpost (square post, pyramidal cap from the post half plus overhang, two axis-aligned painted boards with wedges cut from the board section, iron shoe collar and U-wrap straps) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

Boards are yaw 0. Finger tips are wedges whose base is the board's own thickness×height, bitten into the board so the base face is inside it. The cap is added after the post bevel. The post stays on the origin. Hygiene family 15–19: --stray-vert, --lift-z / --short-shoe, --gap-board, --float-strap, --yaw-boards.

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

-

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

+

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). Hygiene combinatorics match examples/mesh-hygiene-audit (copied, not imported).

+

Intended size: 1.48 m post, boards 0.62 m and 0.54 m, 0.118 m post stock; outer AABB 1.260 × 0.142 × 1.551 m.

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 | 430–580 | 504 / 504 / 504 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2183 / 0.2183 / 0.1746 | | Materials | exactly 3 distinct, ≥24 metal and ≥12 board faces | 3 slots, 66 metal, 24 board | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.368, 0.201, 1.566) m ± 0.01 | (1.3679, 0.2014, 1.5660), zmin 0 | | Collider tris | ≤ 140 | 56 | | Export | written, size > 0 | 45164 / 45164 / 45152 bytes |

-

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

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 320–480 | 376 / 376 / 376 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4681 | | LOD2 ratio | 0.10–0.35 of base | 0.2181 / 0.2181 / 0.1649 | | Materials | exactly 3 distinct, ≥24 metal, ≥12 board, ≥12 wood | 3 slots, 108 metal, 22 board, 60 wood | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.260, 0.142, 1.551) m ± 0.015 | (1.2600, 0.1420, 1.5510), zmin 0 | | Collider tris | ≤ 180 | 52 | | Export | written, size > 0 | 35688 / 35688 / 35676 bytes |

+

Base triangles dropped from 504 to 376 in the quality pass: glued diamond cubes and a solid shoe block became wedges and a four-plate collar. Outer AABB tightened from 1.368 × 0.201 × 1.566 m.

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is leaner on LOD1 and 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.

+

Hygiene

+

Recomputed from the generated mesh, not asserted about the script.

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Non-manifold edges | 0 | 0 | | Loose verts / edges | 0 / 0 | 0 / 0 | | Doubles merged at 1e-5 | 0 | 0 | | Zero-area faces | 0 | 0 | | N-gons | 0 | 0 | | Coplanar disjoint face pairs | 0 | 0 | | Grounded: zmin | within 1e-4 of 0 | 0.0000 | | Named shoe plates | ≥1, each zmin ≤ 0.002 | 4, 0.00000 |

+

Joint fit and orthogonal boards

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Board-to-post BVH gap | ≤ 0.008 m | 0.00500 | | Strap-to-post BVH gap | ≤ 0.008 m | 0.00300 | | Board AABB Y (axis-aligned) | ≤ 0.055 m | 0.0280 |

+

Falsifiers

+

Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.

+

| Flag | Budget violated | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band | 9 | | --stray-vert | loose vertex count is 0 | 15 | | --lift-z | bounding box zmin is 0 | 16 | | --short-shoe | named shoes plant at z=0 | 16 | | --gap-board | board-to-post BVH gap | 17 | | --float-strap | strap-to-post BVH gap | 18 | | --yaw-boards | board AABB Y (orthogonal) | 19 |

+

--short-shoe lifts only the iron shoe plates. After zmin snap the post is the AABB zmin, so the AABB gate would still pass; the named shoe stations fail. --gap-board moves the board inner station outside the post. --float-strap adds Y offset to the U-wrap so it no longer bites. --yaw-boards restores a 0.12 rad yaw; the AABB secondary extent exceeds the thickness bound.

Run

blender --background --python signpost.py --
 blender --background --python signpost.py -- --skip-decimate
+blender --background --python signpost.py -- --stray-vert
+blender --background --python signpost.py -- --lift-z
+blender --background --python signpost.py -- --short-shoe
+blender --background --python signpost.py -- --gap-board
+blender --background --python signpost.py -- --float-strap
+blender --background --python signpost.py -- --yaw-boards
 blender --background --python signpost.py -- --output signpost.png
-

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

+

Smoke passes no flags.

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 ≠ 3 distinct slots, or wood/board/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 |

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family.

+

| 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 ≠ 3 distinct slots, or wood/board/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 | | 15 | Hygiene (--stray-vert lands here) | | 16 | Grounded zmin / named shoes (--lift-z, --short-shoe) | | 17 | Board-to-post gap (--gap-board) | | 18 | Strap-to-post gap (--float-strap) | | 19 | Board yaw / AABB Y (--yaw-boards) |

Source

@@ -289,9 +306,11 @@

Source

pipeline pieces: bmesh construction, UVs, three materials, high-to-low normal bake, LOD chain, convex collider, Unity glTF export. -Budgets are declared below and recomputed from the generated result. -They are not API-contract witnesses. ``--skip-decimate`` skips the LOD -DECIMATE stage so the LOD-ratio budget fails. +Boards are axis-aligned. Finger tips are wedges whose base is the +board's own section, not glued cubes. The cap is a 4-gon pyramid from +the post half plus overhang, added after bevel. The post stays on the +origin. Hygiene family 15–19: ``--stray-vert``, ``--short-shoe``, +``--gap-board``, ``--float-strap``, ``--yaw-boards``. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -311,6 +330,7 @@

Source

import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -321,18 +341,29 @@

Source

POST_W = 0.118 POST_H = 1.48 -CAP_H = 0.058 -SHOE_H = 0.048 -SHOE_SCALE = 1.34 +CAP_H = 0.075 +CAP_OVERHANG = 0.012 +SHOE_H = 0.042 +IRON_T = 0.014 +COLLAR_BITE = 0.004 +TENON = 0.005 BOARD_T = 0.028 -BOARD_H = 0.112 -BOARD_A = (1.24, 0.62, 1.0, 0.12) -BOARD_B = (1.00, 0.54, -1.0, -0.18) - -BBOX_TOL = 0.01 -OUTER_SIZE = (1.368, 0.201, 1.566) -BASE_TRIS_MIN = 430 -BASE_TRIS_MAX = 580 +BOARD_H_A = 0.112 +BOARD_H_B = 0.100 +BOARD_A = (1.24, 0.62, 1.0) +BOARD_B = (1.00, 0.54, -1.0) +TIP_LEN = 0.070 +TIP_BITE = 0.004 +BAND_Z = 0.22 +LIFT_SHOE = 0.08 +GAP_BOARD = 0.12 +FLOAT_STRAP = 0.06 +YAW_FAIL = 0.12 + +BBOX_TOL = 0.015 +OUTER_SIZE = (1.260, 0.142, 1.551) +BASE_TRIS_MIN = 320 +BASE_TRIS_MAX = 480 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -342,7 +373,7 @@

Source

MATERIAL_COUNT = 3 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 140 +COLLIDER_TRIS_MAX = 180 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 METAL_FACES_MIN = 24 @@ -353,6 +384,17 @@

Source

BOARD_IDX = 1 METAL_IDX = 2 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 0.002 +ZFIGHT_COS = 0.98 +SHOE_COUNT = 1 +SHOE_ZMIN_MAX = 0.002 +BOARD_GAP_MAX = 0.008 +STRAP_GAP_MAX = 0.008 +BOARD_DY_MAX = 0.055 + def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" @@ -379,13 +421,6 @@

Source

eval_obj.to_mesh_clear() -def deselect_all(): - for ob in list(bpy.context.view_layer.objects): - if ob is None: - continue - ob.select_set(False) - - def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cube(bm, size=1.0) verts = geo["verts"] @@ -400,50 +435,11 @@

Source

return verts -def add_oriented_box(bm, a, b, scale_xy, mat_idx): - a = Vector(a) - b = Vector(b) - delta = b - a - length = delta.length - if length < 1e-8: - return [] - quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) - eul = quat.to_euler("XYZ") - return add_box( - bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), - ) - - -def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( - bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius, - radius2=radius, - depth=depth, - ) - verts = geo["verts"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx - return verts - - def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=segments > 4, segments=segments, radius1=radius1, radius2=radius2, @@ -460,44 +456,49 @@

Source

return verts -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 14 - n_minor = 7 - rings = [] - for i in range(n_major): - u = i * (2.0 * math.pi / n_major) - ring = [] - for j in range(n_minor): - v = j * (2.0 * math.pi / n_minor) - x = (major + minor * math.cos(v)) * math.cos(u) - y = (major + minor * math.cos(v)) * math.sin(u) - z = minor * math.sin(v) - ring.append(bm.verts.new((x, y, z))) - rings.append(ring) - bm.verts.ensure_lookup_table() - for i in range(n_major): - i2 = (i + 1) % n_major - for j in range(n_minor): - j2 = (j + 1) % n_minor - face = bm.faces.new( - (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2]) - ) - face.material_index = mat_idx - verts = [v for ring in rings for v in ring] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - return verts +def add_collar(bm, z, half, t, h, mat_idx, closed=True, omit_x_sign=None, extra_y=0.0): + loc_y = half - COLLAR_BITE + t * 0.5 + extra_y + add_box(bm, (0.0, loc_y, z), (2.0 * half + t * 0.55, t, h), mat_idx) + add_box(bm, (0.0, -loc_y, z), (2.0 * half + t * 0.55, t, h), mat_idx) + loc_x = half - COLLAR_BITE + t * 0.5 + extra_y + for sx in (-1.0, 1.0): + if omit_x_sign is not None and sx == omit_x_sign: + continue + if not closed: + continue + add_box( + bm, + (sx * loc_x, 0.0, z), + (t, 2.0 * half + t * 0.15, h), + mat_idx, + ) -def add_square_band(bm, z, half, t, h, mat_idx): - metal = [] - metal.extend(add_box(bm, (0.0, half + t / 2.0, z), (2.0 * half + 2.0 * t, t, h), mat_idx)) - metal.extend(add_box(bm, (0.0, -(half + t / 2.0), z), (2.0 * half + 2.0 * t, t, h), mat_idx)) - metal.extend(add_box(bm, (half + t / 2.0, 0.0, z), (t, 2.0 * half, h), mat_idx)) - metal.extend(add_box(bm, (-(half + t / 2.0), 0.0, z), (t, 2.0 * half, h), mat_idx)) - return metal +def add_wedge_tip(bm, origin, sign, yaw, height, thickness, mat_idx): + rot = Euler((0.0, 0.0, yaw)).to_matrix() + hy = thickness * 0.5 + hz = height * 0.5 + sx = 1.0 if sign > 0.0 else -1.0 + local = ( + (0.0, -hy, -hz), + (0.0, hy, -hz), + (0.0, hy, hz), + (0.0, -hy, hz), + (sx * TIP_LEN, -hy, 0.0), + (sx * TIP_LEN, hy, 0.0), + ) + verts = [bm.verts.new(rot @ Vector(p) + Vector(origin)) for p in local] + faces = ( + (verts[0], verts[1], verts[2], verts[3]), + (verts[0], verts[4], verts[5], verts[1]), + (verts[3], verts[2], verts[5], verts[4]), + (verts[0], verts[3], verts[4]), + (verts[1], verts[5], verts[2]), + ) + for fverts in faces: + face = bm.faces.new(fverts) + face.material_index = mat_idx + return verts def pack_uvs(bm, margin=0.08): @@ -543,14 +544,19 @@

Source

) -def build_signpost_mesh(name, bevel_offset, bevel_segments): +def build_signpost_mesh( + name, + bevel_offset, + bevel_segments, + short_shoe=False, + gap_board=False, + float_strap=False, + yaw_boards=False, +): bm = bmesh.new() try: wood = [] - boards = [] - metal = [] half = POST_W / 2.0 - wood.extend( add_box( bm, @@ -559,26 +565,6 @@

Source

WOOD_IDX, ) ) - wood.extend( - add_box( - bm, - (0.0, 0.0, POST_H + 0.014), - (POST_W * 1.16, POST_W * 1.16, 0.028), - WOOD_IDX, - ) - ) - wood.extend( - add_cone( - bm, - (0.0, 0.0, POST_H + 0.028 + CAP_H / 2.0), - POST_W * 0.72, - 0.008, - CAP_H, - 4, - WOOD_IDX, - euler=(0.0, 0.0, math.pi / 4.0), - ) - ) if bevel_offset > 0.0: edges = list({e for v in wood for e in v.link_edges}) @@ -594,76 +580,66 @@

Source

for f in ret.get("faces") or []: f.material_index = WOOD_IDX - for z, length, sign, yaw in (BOARD_A, BOARD_B): - eul = Euler((0.0, 0.0, yaw)) - rot = eul.to_matrix() - inner = Vector((sign * (half - 0.016), 0.0, z)) - center = inner + rot @ Vector((sign * length / 2.0, 0.0, 0.0)) - boards.extend( - add_box( - bm, - (center.x, center.y, center.z), - (length, BOARD_T, BOARD_H), - BOARD_IDX, - euler=(0.0, 0.0, yaw), - ) - ) - tip = inner + rot @ Vector((sign * (length + 0.004), 0.0, 0.0)) - boards.extend( - add_box( - bm, - (tip.x, tip.y, tip.z), - (0.088, BOARD_T, 0.088), - BOARD_IDX, - euler=(0.0, math.pi / 4.0, yaw), - ) - ) - t = 0.012 - h = BOARD_H + 0.024 - metal.extend( - add_box( - bm, - (sign * (half + t / 2.0), 0.0, z), - (t, POST_W * 0.94, h), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, half + t / 2.0, z), - (POST_W * 0.94, t, h), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, -(half + t / 2.0), z), - (POST_W * 0.94, t, h), - METAL_IDX, - ) - ) + cap_half = half + CAP_OVERHANG + add_cone( + bm, + (0.0, 0.0, POST_H + CAP_H / 2.0 - 0.004), + cap_half * math.sqrt(2.0), + 0.018, + CAP_H, + 4, + WOOD_IDX, + euler=(0.0, 0.0, math.pi / 4.0), + ) - metal.extend( + yaw = YAW_FAIL if yaw_boards else 0.0 + extra = GAP_BOARD if gap_board else 0.0 + strap_extra = FLOAT_STRAP if float_strap else 0.0 + specs = ( + (BOARD_A[0], BOARD_A[1], BOARD_A[2], BOARD_H_A, BOARD_T), + (BOARD_B[0], BOARD_B[1], BOARD_B[2], BOARD_H_B, BOARD_T * 0.92), + ) + for z, length, sign, bh, bt in specs: + inner = sign * (half - TENON + extra) + rect_len = length - TIP_LEN + center_x = inner + sign * rect_len / 2.0 add_box( bm, - (0.0, 0.0, SHOE_H / 2.0), - (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H), + (center_x, 0.0, z), + (rect_len, bt, bh), + BOARD_IDX, + euler=(0.0, 0.0, sign * yaw), + ) + outer = inner + sign * rect_len - sign * TIP_BITE + add_wedge_tip( + bm, + (outer, 0.0, z), + sign, + sign * yaw, + bh, + bt, + BOARD_IDX, + ) + add_collar( + bm, + z, + half, + IRON_T, + bh + 0.020, METAL_IDX, + closed=True, + omit_x_sign=sign, + extra_y=strap_extra, ) - ) - metal.extend(add_square_band(bm, 0.22, half, 0.012, 0.028, METAL_IDX)) - xs = [v.co.x for v in bm.verts] - ys = [v.co.y for v in bm.verts] + shoe_z = SHOE_H / 2.0 + (LIFT_SHOE if short_shoe else 0.0) + add_collar(bm, shoe_z, half, IRON_T, SHOE_H, METAL_IDX, closed=True) + add_collar(bm, BAND_Z, half, IRON_T, 0.028, METAL_IDX, closed=True) + add_collar(bm, 0.46, half, IRON_T, 0.028, METAL_IDX, closed=True) + zs = [v.co.z for v in bm.verts] - rcx = 0.5 * (min(xs) + max(xs)) - rcy = 0.5 * (min(ys) + max(ys)) zmin = min(zs) for v in bm.verts: - v.co.x -= rcx - v.co.y -= rcy v.co.z -= zmin if v.co.z < 0.0: v.co.z = 0.0 @@ -684,13 +660,25 @@

Source

return out -def principled(name, color, metallic, roughness): +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 6.0 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) return mat @@ -705,10 +693,11 @@

Source

def world_bbox(obj): - corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] - xs = [c.x for c in corners] - ys = [c.y for c in corners] - zs = [c.z for c in corners] + mat = obj.matrix_world + pts = [mat @ v.co for v in obj.data.vertices] + xs = [p.x for p in pts] + ys = [p.y for p in pts] + zs = [p.z for p in pts] return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) @@ -771,10 +760,192 @@

Source

return collider +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, + "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, + "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf, + } + + +def zfight_pairs(me): + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +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: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), + max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), + ) + + +def mat_of(me, group): + member = set(group) + for poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def shell_bvh_gap(me, ga, gb): + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + keep_a, keep_b = set(ga), set(gb) + drop_a = [f for f in bm_a.faces if not all(v.index in keep_a for v in f.verts)] + drop_b = [f for f in bm_b.faces if not all(v.index in keep_b for v in f.verts)] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for v in bm_a.verts: + hit = tree.find_nearest(v.co) + if hit[0] is not None: + best = min(best, hit[3]) + for f in bm_a.faces: + hit = tree.find_nearest(f.calc_center_median()) + if hit[0] is not None: + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, POST_H * 0.55)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + +def joint_audit(me): + groups = shells(me) + posts = [] + boards = [] + shoes = [] + straps = [] + for g in groups: + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + mat = mat_of(me, g) + if mat == WOOD_IDX and dz > 0.80 and dx < 0.22 and dy < 0.22: + posts.append((g, a)) + elif mat == BOARD_IDX and dx > 0.25: + boards.append((g, a)) + elif mat == METAL_IDX and a[2] < 0.08 and dz < 0.08: + shoes.append((g, a)) + elif mat == METAL_IDX and a[2] > 0.70: + straps.append((g, a)) + board_gap = 0.0 + for bg, _ in boards: + best = 1e9 + for pg, _ in posts: + best = min(best, shell_bvh_gap(me, bg, pg)) + if best < 1e8: + board_gap = max(board_gap, best) + strap_gap = 0.0 + for sg, _ in straps: + best = 1e9 + for pg, _ in posts: + best = min(best, shell_bvh_gap(me, sg, pg)) + if best < 1e8: + strap_gap = max(strap_gap, best) + board_dy = max((a[4] - a[1] for _, a in boards), default=0.0) + shoe_z = min((a[2] for _, a in shoes), default=1.0) + return { + "posts": len(posts), + "boards": len(boards), + "shoes": len(shoes), + "straps": len(straps), + "board_gap": board_gap if boards and posts else 1e9, + "strap_gap": strap_gap if straps and posts else 1e9, + "board_dy": board_dy, + "shoe_z": shoe_z, + } + + def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None - img = bpy.data.images.new("SignpostNrm", size, size, alpha=True, float_buffer=False) + img = bpy.data.images.new("SignNrm", size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" nodes = target_mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") @@ -791,7 +962,8 @@

Source

scene.cycles.device = "CPU" scene.cycles.samples = 1 scene.cycles.use_denoising = False - deselect_all() + 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 @@ -809,7 +981,8 @@

Source

def export_unity(path, objects): - deselect_all() + 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] @@ -823,16 +996,46 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_shoe=False, + gap_board=False, + float_strap=False, + yaw_boards=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2) - high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4) - wood = principled("SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62) - board = principled("SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52) - metal = principled("SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38) + kw = dict( + short_shoe=short_shoe, + gap_board=gap_board, + float_strap=float_strap, + yaw_boards=yaw_boards, + ) + low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2, **kw) + high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4, **kw) + wood = principled( + "SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62, + noise_scale=14.0, wear=(0.22, 0.10, 0.04, 1.0), + ) + board = principled( + "SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52, + noise_scale=10.0, wear=(0.55, 0.38, 0.14, 1.0), + ) + metal = principled( + "SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38, + noise_scale=18.0, wear=(0.22, 0.18, 0.14, 1.0), + ) assign_slots(low, wood, board, metal) assign_slots(high, wood, board, metal) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += 0.05 + low.data.update() + if low.data is None or len(low.data.polygons) < 6: return fail("signpost mesh did not build", 3), None, None, None, None, None @@ -864,7 +1067,7 @@

Source

r2 = lod2_tris / base_tris if base_tris else 0.0 collider_src = build_signpost_mesh( - "SignpostColSrc", bevel_offset=0.0, bevel_segments=1 + "SignpostColSrc", bevel_offset=0.0, bevel_segments=1, **kw ) collider = convex_hull_collider(collider_src, "SignpostCollider") bpy.data.objects.remove(collider_src, do_unlink=True) @@ -879,6 +1082,10 @@

Source

export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + jnt = joint_audit(low.data) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " @@ -896,6 +1103,17 @@

Source

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 posts={jnt['posts']} boards={jnt['boards']} " + f"shoes={jnt['shoes']} straps={jnt['straps']} " + f"board_gap={jnt['board_gap']:.5f} strap_gap={jnt['strap_gap']:.5f} " + f"board_dy={jnt['board_dy']:.4f} shoe_z={jnt['shoe_z']:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -932,6 +1150,32 @@

Source

f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7, ), None, None, None, None, None + if ( + bb[2] > ZMIN_EPS + or jnt["shoes"] < SHOE_COUNT + or jnt["shoe_z"] > SHOE_ZMIN_MAX + ): + return fail( + f"grounded zmin={bb[2]:.5f} shoes={jnt['shoes']} " + f"shoe_z={jnt['shoe_z']:.5f}", + 16, + ), None, None, None, None, None + if jnt["boards"] < 2 or jnt["posts"] < 1 or jnt["board_gap"] > BOARD_GAP_MAX: + return fail( + f"board gap {jnt['board_gap']:.5f} boards={jnt['boards']} " + f"posts={jnt['posts']}", + 17, + ), None, None, None, None, None + if jnt["straps"] < 1 or jnt["strap_gap"] > STRAP_GAP_MAX: + return fail( + f"strap gap {jnt['strap_gap']:.5f} straps={jnt['straps']}", + 18, + ), None, None, None, None, None + if jnt["board_dy"] > BOARD_DY_MAX: + return fail( + f"board dy {jnt['board_dy']:.4f} > {BOARD_DY_MAX}", + 19, + ), None, None, None, None, None if ( abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL @@ -965,6 +1209,16 @@

Source

), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}", + 15, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -986,7 +1240,6 @@

Source

ob.hide_viewport = True low.rotation_euler.z = math.radians(-12.0) - low.rotation_euler.x = math.radians(0.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -1078,14 +1331,24 @@

Source

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", - help="falsification: skip the LOD DECIMATE stage", - ) + 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("--short-shoe", action="store_true") + p.add_argument("--gap-board", action="store_true") + p.add_argument("--float-strap", action="store_true") + p.add_argument("--yaw-boards", action="store_true") args = p.parse_args(argv) - code, low, _high, wood, tex, _col = check(args.skip_decimate) + code, low, _high, wood, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + short_shoe=args.short_shoe, + gap_board=args.gap_board, + float_strap=args.float_strap, + yaw_boards=args.yaw_boards, + ) if code: return code if args.output: diff --git a/showcase/gallery.json b/showcase/gallery.json index 6f06cb0e..682f2630 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -225,7 +225,7 @@ "name": "signpost", "dir": "showcase/signpost", "teaches": "A procedural timber fingerboard signpost through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 504 tris, three materials with 66 metal and 24 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.368×0.201×1.566 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 376 tris, three materials with 108 metal, 22 board and 60 wood faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.260×0.142×1.551 m at zmin 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD1/LOD2), convex collider 52 tris, non-empty glTF. Hygiene all zero. Board-to-post gap 5 mm, strap gap 3 mm, board AABB Y 28 mm. --skip-decimate exits 9; --stray-vert 15; --lift-z / --short-shoe 16; --gap-board 17; --float-strap 18; --yaw-boards 19.", "hero": "docs/gallery/assets/signpost-hero.webp", "preview": "showcase/signpost/preview.webp", "tags": [ diff --git a/showcase/signpost/README.md b/showcase/signpost/README.md index 6a66060f..20f024ef 100644 --- a/showcase/signpost/README.md +++ b/showcase/signpost/README.md @@ -1,10 +1,17 @@ # Signpost A showcase piece, not an example. Procedural timber fingerboard -signpost (square post, collar and pyramidal cap, two painted planks -with diamond tips, iron shoe and straps) then the shipped pipeline: -unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex -collider, Unity glTF export. +signpost (square post, pyramidal cap from the post half plus overhang, +two axis-aligned painted boards with wedges cut from the board +section, iron shoe collar and U-wrap straps) then the shipped +pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, +convex collider, Unity glTF export. + +Boards are yaw 0. Finger tips are wedges whose base is the board's own +thickness×height, bitten into the board so the base face is inside it. +The cap is added after the post bevel. The post stays on the origin. +Hygiene family 15–19: `--stray-vert`, `--lift-z` / `--short-shoe`, +`--gap-board`, `--float-strap`, `--yaw-boards`. It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -14,6 +21,11 @@ witness an API contract. "It rendered without error" is not a check. `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). +Hygiene combinatorics match `examples/mesh-hygiene-audit` (copied, not +imported). + +Intended size: 1.48 m post, boards 0.62 m and 0.54 m, 0.118 m post +stock; outer AABB 1.260 × 0.142 × 1.551 m. ## Budgets @@ -22,39 +34,92 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 430–580 | 504 / 504 / 504 | -| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2183 / 0.2183 / 0.1746 | -| Materials | exactly 3 distinct, ≥24 metal and ≥12 board faces | 3 slots, 66 metal, 24 board | +| Base triangles | 320–480 | 376 / 376 / 376 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4681 | +| LOD2 ratio | 0.10–0.35 of base | 0.2181 / 0.2181 / 0.1649 | +| Materials | exactly 3 distinct, ≥24 metal, ≥12 board, ≥12 wood | 3 slots, 108 metal, 22 board, 60 wood | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.368, 0.201, 1.566) m ± 0.01 | (1.3679, 0.2014, 1.5660), zmin 0 | -| Collider tris | ≤ 140 | 56 | -| Export | written, size > 0 | 45164 / 45164 / 45152 bytes | +| Outer AABB | (1.260, 0.142, 1.551) m ± 0.015 | (1.2600, 0.1420, 1.5510), zmin 0 | +| Collider tris | ≤ 180 | 52 | +| Export | written, size > 0 | 35688 / 35688 / 35676 bytes | + +Base triangles dropped from **504 to 376** in the quality pass: glued +diamond cubes and a solid shoe block became wedges and a four-plate +collar. Outer AABB tightened from 1.368 × 0.201 × 1.566 m. 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 +5.2.1 is leaner on LOD1 and 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. +### Hygiene + +Recomputed from the generated mesh, not asserted about the script. + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Non-manifold edges | 0 | 0 | +| Loose verts / edges | 0 / 0 | 0 / 0 | +| Doubles merged at 1e-5 | 0 | 0 | +| Zero-area faces | 0 | 0 | +| N-gons | 0 | 0 | +| Coplanar disjoint face pairs | 0 | 0 | +| Grounded: `zmin` | within 1e-4 of 0 | 0.0000 | +| Named shoe plates | ≥1, each `zmin` ≤ 0.002 | 4, 0.00000 | + +### Joint fit and orthogonal boards + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Board-to-post BVH gap | ≤ 0.008 m | 0.00500 | +| Strap-to-post BVH gap | ≤ 0.008 m | 0.00300 | +| Board AABB Y (axis-aligned) | ≤ 0.055 m | 0.0280 | + +### Falsifiers + +Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 +and returned the same code on each. + +| Flag | Budget violated | Exit | +| --- | --- | --- | +| `--skip-decimate` | LOD1 ratio band | 9 | +| `--stray-vert` | loose vertex count is 0 | 15 | +| `--lift-z` | bounding box `zmin` is 0 | 16 | +| `--short-shoe` | named shoes plant at z=0 | 16 | +| `--gap-board` | board-to-post BVH gap | 17 | +| `--float-strap` | strap-to-post BVH gap | 18 | +| `--yaw-boards` | board AABB Y (orthogonal) | 19 | + +`--short-shoe` lifts only the iron shoe plates. After zmin snap the +post is the AABB `zmin`, so the AABB gate would still pass; the named +shoe stations fail. `--gap-board` moves the board inner station outside +the post. `--float-strap` adds Y offset to the U-wrap so it no longer +bites. `--yaw-boards` restores a 0.12 rad yaw; the AABB secondary +extent exceeds the thickness bound. ## Run ```bash blender --background --python signpost.py -- blender --background --python signpost.py -- --skip-decimate +blender --background --python signpost.py -- --stray-vert +blender --background --python signpost.py -- --lift-z +blender --background --python signpost.py -- --short-shoe +blender --background --python signpost.py -- --gap-board +blender --background --python signpost.py -- --float-strap +blender --background --python signpost.py -- --yaw-boards blender --background --python signpost.py -- --output signpost.png ``` -Smoke does not pass `--output` or `--skip-decimate`. +Smoke passes no flags. ## Exit codes File-local. `9` is a valid check code. `10` is reserved for -`gallery_framing.check_framing` on the `--output` path. +`gallery_framing.check_framing` on the `--output` path. `15`–`19` are the +hygiene and joint-fit family. | Code | Meaning | | --- | --- | @@ -73,3 +138,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 (`--stray-vert` lands here) | +| 16 | Grounded zmin / named shoes (`--lift-z`, `--short-shoe`) | +| 17 | Board-to-post gap (`--gap-board`) | +| 18 | Strap-to-post gap (`--float-strap`) | +| 19 | Board yaw / AABB Y (`--yaw-boards`) | diff --git a/showcase/signpost/preview.webp b/showcase/signpost/preview.webp index 266e72f1c691a680167b862e5c7d5483f28c46a1..c8c1ef4e0ed08f2ba1bcc86da2c5875fd4d0dbbe 100644 GIT binary patch literal 7580 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 diff --git a/showcase/signpost/signpost.py b/showcase/signpost/signpost.py index 82e6702f..27d53062 100644 --- a/showcase/signpost/signpost.py +++ b/showcase/signpost/signpost.py @@ -5,9 +5,11 @@ pipeline pieces: bmesh construction, UVs, three materials, high-to-low normal bake, LOD chain, convex collider, Unity glTF export. -Budgets are declared below and recomputed from the generated result. -They are not API-contract witnesses. ``--skip-decimate`` skips the LOD -DECIMATE stage so the LOD-ratio budget fails. +Boards are axis-aligned. Finger tips are wedges whose base is the +board's own section, not glued cubes. The cap is a 4-gon pyramid from +the post half plus overhang, added after bevel. The post stays on the +origin. Hygiene family 15–19: ``--stray-vert``, ``--short-shoe``, +``--gap-board``, ``--float-strap``, ``--yaw-boards``. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -27,6 +29,7 @@ import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -37,18 +40,29 @@ POST_W = 0.118 POST_H = 1.48 -CAP_H = 0.058 -SHOE_H = 0.048 -SHOE_SCALE = 1.34 +CAP_H = 0.075 +CAP_OVERHANG = 0.012 +SHOE_H = 0.042 +IRON_T = 0.014 +COLLAR_BITE = 0.004 +TENON = 0.005 BOARD_T = 0.028 -BOARD_H = 0.112 -BOARD_A = (1.24, 0.62, 1.0, 0.12) -BOARD_B = (1.00, 0.54, -1.0, -0.18) - -BBOX_TOL = 0.01 -OUTER_SIZE = (1.368, 0.201, 1.566) -BASE_TRIS_MIN = 430 -BASE_TRIS_MAX = 580 +BOARD_H_A = 0.112 +BOARD_H_B = 0.100 +BOARD_A = (1.24, 0.62, 1.0) +BOARD_B = (1.00, 0.54, -1.0) +TIP_LEN = 0.070 +TIP_BITE = 0.004 +BAND_Z = 0.22 +LIFT_SHOE = 0.08 +GAP_BOARD = 0.12 +FLOAT_STRAP = 0.06 +YAW_FAIL = 0.12 + +BBOX_TOL = 0.015 +OUTER_SIZE = (1.260, 0.142, 1.551) +BASE_TRIS_MIN = 320 +BASE_TRIS_MAX = 480 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -58,7 +72,7 @@ MATERIAL_COUNT = 3 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 140 +COLLIDER_TRIS_MAX = 180 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 METAL_FACES_MIN = 24 @@ -69,6 +83,17 @@ BOARD_IDX = 1 METAL_IDX = 2 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 0.002 +ZFIGHT_COS = 0.98 +SHOE_COUNT = 1 +SHOE_ZMIN_MAX = 0.002 +BOARD_GAP_MAX = 0.008 +STRAP_GAP_MAX = 0.008 +BOARD_DY_MAX = 0.055 + def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" @@ -95,13 +120,6 @@ def evaluated_triangle_count(obj): eval_obj.to_mesh_clear() -def deselect_all(): - for ob in list(bpy.context.view_layer.objects): - if ob is None: - continue - ob.select_set(False) - - def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cube(bm, size=1.0) verts = geo["verts"] @@ -116,50 +134,11 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): return verts -def add_oriented_box(bm, a, b, scale_xy, mat_idx): - a = Vector(a) - b = Vector(b) - delta = b - a - length = delta.length - if length < 1e-8: - return [] - quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) - eul = quat.to_euler("XYZ") - return add_box( - bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), - ) - - -def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( - bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius, - radius2=radius, - depth=depth, - ) - verts = geo["verts"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx - return verts - - def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=segments > 4, segments=segments, radius1=radius1, radius2=radius2, @@ -176,44 +155,49 @@ def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0. return verts -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 14 - n_minor = 7 - rings = [] - for i in range(n_major): - u = i * (2.0 * math.pi / n_major) - ring = [] - for j in range(n_minor): - v = j * (2.0 * math.pi / n_minor) - x = (major + minor * math.cos(v)) * math.cos(u) - y = (major + minor * math.cos(v)) * math.sin(u) - z = minor * math.sin(v) - ring.append(bm.verts.new((x, y, z))) - rings.append(ring) - bm.verts.ensure_lookup_table() - for i in range(n_major): - i2 = (i + 1) % n_major - for j in range(n_minor): - j2 = (j + 1) % n_minor - face = bm.faces.new( - (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2]) - ) - face.material_index = mat_idx - verts = [v for ring in rings for v in ring] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - return verts +def add_collar(bm, z, half, t, h, mat_idx, closed=True, omit_x_sign=None, extra_y=0.0): + loc_y = half - COLLAR_BITE + t * 0.5 + extra_y + add_box(bm, (0.0, loc_y, z), (2.0 * half + t * 0.55, t, h), mat_idx) + add_box(bm, (0.0, -loc_y, z), (2.0 * half + t * 0.55, t, h), mat_idx) + loc_x = half - COLLAR_BITE + t * 0.5 + extra_y + for sx in (-1.0, 1.0): + if omit_x_sign is not None and sx == omit_x_sign: + continue + if not closed: + continue + add_box( + bm, + (sx * loc_x, 0.0, z), + (t, 2.0 * half + t * 0.15, h), + mat_idx, + ) -def add_square_band(bm, z, half, t, h, mat_idx): - metal = [] - metal.extend(add_box(bm, (0.0, half + t / 2.0, z), (2.0 * half + 2.0 * t, t, h), mat_idx)) - metal.extend(add_box(bm, (0.0, -(half + t / 2.0), z), (2.0 * half + 2.0 * t, t, h), mat_idx)) - metal.extend(add_box(bm, (half + t / 2.0, 0.0, z), (t, 2.0 * half, h), mat_idx)) - metal.extend(add_box(bm, (-(half + t / 2.0), 0.0, z), (t, 2.0 * half, h), mat_idx)) - return metal +def add_wedge_tip(bm, origin, sign, yaw, height, thickness, mat_idx): + rot = Euler((0.0, 0.0, yaw)).to_matrix() + hy = thickness * 0.5 + hz = height * 0.5 + sx = 1.0 if sign > 0.0 else -1.0 + local = ( + (0.0, -hy, -hz), + (0.0, hy, -hz), + (0.0, hy, hz), + (0.0, -hy, hz), + (sx * TIP_LEN, -hy, 0.0), + (sx * TIP_LEN, hy, 0.0), + ) + verts = [bm.verts.new(rot @ Vector(p) + Vector(origin)) for p in local] + faces = ( + (verts[0], verts[1], verts[2], verts[3]), + (verts[0], verts[4], verts[5], verts[1]), + (verts[3], verts[2], verts[5], verts[4]), + (verts[0], verts[3], verts[4]), + (verts[1], verts[5], verts[2]), + ) + for fverts in faces: + face = bm.faces.new(fverts) + face.material_index = mat_idx + return verts def pack_uvs(bm, margin=0.08): @@ -259,14 +243,19 @@ def pack_uvs(bm, margin=0.08): ) -def build_signpost_mesh(name, bevel_offset, bevel_segments): +def build_signpost_mesh( + name, + bevel_offset, + bevel_segments, + short_shoe=False, + gap_board=False, + float_strap=False, + yaw_boards=False, +): bm = bmesh.new() try: wood = [] - boards = [] - metal = [] half = POST_W / 2.0 - wood.extend( add_box( bm, @@ -275,26 +264,6 @@ def build_signpost_mesh(name, bevel_offset, bevel_segments): WOOD_IDX, ) ) - wood.extend( - add_box( - bm, - (0.0, 0.0, POST_H + 0.014), - (POST_W * 1.16, POST_W * 1.16, 0.028), - WOOD_IDX, - ) - ) - wood.extend( - add_cone( - bm, - (0.0, 0.0, POST_H + 0.028 + CAP_H / 2.0), - POST_W * 0.72, - 0.008, - CAP_H, - 4, - WOOD_IDX, - euler=(0.0, 0.0, math.pi / 4.0), - ) - ) if bevel_offset > 0.0: edges = list({e for v in wood for e in v.link_edges}) @@ -310,76 +279,66 @@ def build_signpost_mesh(name, bevel_offset, bevel_segments): for f in ret.get("faces") or []: f.material_index = WOOD_IDX - for z, length, sign, yaw in (BOARD_A, BOARD_B): - eul = Euler((0.0, 0.0, yaw)) - rot = eul.to_matrix() - inner = Vector((sign * (half - 0.016), 0.0, z)) - center = inner + rot @ Vector((sign * length / 2.0, 0.0, 0.0)) - boards.extend( - add_box( - bm, - (center.x, center.y, center.z), - (length, BOARD_T, BOARD_H), - BOARD_IDX, - euler=(0.0, 0.0, yaw), - ) - ) - tip = inner + rot @ Vector((sign * (length + 0.004), 0.0, 0.0)) - boards.extend( - add_box( - bm, - (tip.x, tip.y, tip.z), - (0.088, BOARD_T, 0.088), - BOARD_IDX, - euler=(0.0, math.pi / 4.0, yaw), - ) - ) - t = 0.012 - h = BOARD_H + 0.024 - metal.extend( - add_box( - bm, - (sign * (half + t / 2.0), 0.0, z), - (t, POST_W * 0.94, h), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, half + t / 2.0, z), - (POST_W * 0.94, t, h), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, -(half + t / 2.0), z), - (POST_W * 0.94, t, h), - METAL_IDX, - ) - ) + cap_half = half + CAP_OVERHANG + add_cone( + bm, + (0.0, 0.0, POST_H + CAP_H / 2.0 - 0.004), + cap_half * math.sqrt(2.0), + 0.018, + CAP_H, + 4, + WOOD_IDX, + euler=(0.0, 0.0, math.pi / 4.0), + ) - metal.extend( + yaw = YAW_FAIL if yaw_boards else 0.0 + extra = GAP_BOARD if gap_board else 0.0 + strap_extra = FLOAT_STRAP if float_strap else 0.0 + specs = ( + (BOARD_A[0], BOARD_A[1], BOARD_A[2], BOARD_H_A, BOARD_T), + (BOARD_B[0], BOARD_B[1], BOARD_B[2], BOARD_H_B, BOARD_T * 0.92), + ) + for z, length, sign, bh, bt in specs: + inner = sign * (half - TENON + extra) + rect_len = length - TIP_LEN + center_x = inner + sign * rect_len / 2.0 add_box( bm, - (0.0, 0.0, SHOE_H / 2.0), - (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H), + (center_x, 0.0, z), + (rect_len, bt, bh), + BOARD_IDX, + euler=(0.0, 0.0, sign * yaw), + ) + outer = inner + sign * rect_len - sign * TIP_BITE + add_wedge_tip( + bm, + (outer, 0.0, z), + sign, + sign * yaw, + bh, + bt, + BOARD_IDX, + ) + add_collar( + bm, + z, + half, + IRON_T, + bh + 0.020, METAL_IDX, + closed=True, + omit_x_sign=sign, + extra_y=strap_extra, ) - ) - metal.extend(add_square_band(bm, 0.22, half, 0.012, 0.028, METAL_IDX)) - xs = [v.co.x for v in bm.verts] - ys = [v.co.y for v in bm.verts] + shoe_z = SHOE_H / 2.0 + (LIFT_SHOE if short_shoe else 0.0) + add_collar(bm, shoe_z, half, IRON_T, SHOE_H, METAL_IDX, closed=True) + add_collar(bm, BAND_Z, half, IRON_T, 0.028, METAL_IDX, closed=True) + add_collar(bm, 0.46, half, IRON_T, 0.028, METAL_IDX, closed=True) + zs = [v.co.z for v in bm.verts] - rcx = 0.5 * (min(xs) + max(xs)) - rcy = 0.5 * (min(ys) + max(ys)) zmin = min(zs) for v in bm.verts: - v.co.x -= rcx - v.co.y -= rcy v.co.z -= zmin if v.co.z < 0.0: v.co.z = 0.0 @@ -400,13 +359,25 @@ def build_signpost_mesh(name, bevel_offset, bevel_segments): return out -def principled(name, color, metallic, roughness): +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 6.0 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) return mat @@ -421,10 +392,11 @@ def assign_slots(obj, wood, board, metal): def world_bbox(obj): - corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] - xs = [c.x for c in corners] - ys = [c.y for c in corners] - zs = [c.z for c in corners] + mat = obj.matrix_world + pts = [mat @ v.co for v in obj.data.vertices] + xs = [p.x for p in pts] + ys = [p.y for p in pts] + zs = [p.z for p in pts] return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) @@ -487,10 +459,192 @@ def convex_hull_collider(obj, name): return collider +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, + "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, + "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf, + } + + +def zfight_pairs(me): + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +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: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), + max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), + ) + + +def mat_of(me, group): + member = set(group) + for poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def shell_bvh_gap(me, ga, gb): + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + keep_a, keep_b = set(ga), set(gb) + drop_a = [f for f in bm_a.faces if not all(v.index in keep_a for v in f.verts)] + drop_b = [f for f in bm_b.faces if not all(v.index in keep_b for v in f.verts)] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for v in bm_a.verts: + hit = tree.find_nearest(v.co) + if hit[0] is not None: + best = min(best, hit[3]) + for f in bm_a.faces: + hit = tree.find_nearest(f.calc_center_median()) + if hit[0] is not None: + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, POST_H * 0.55)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + +def joint_audit(me): + groups = shells(me) + posts = [] + boards = [] + shoes = [] + straps = [] + for g in groups: + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + mat = mat_of(me, g) + if mat == WOOD_IDX and dz > 0.80 and dx < 0.22 and dy < 0.22: + posts.append((g, a)) + elif mat == BOARD_IDX and dx > 0.25: + boards.append((g, a)) + elif mat == METAL_IDX and a[2] < 0.08 and dz < 0.08: + shoes.append((g, a)) + elif mat == METAL_IDX and a[2] > 0.70: + straps.append((g, a)) + board_gap = 0.0 + for bg, _ in boards: + best = 1e9 + for pg, _ in posts: + best = min(best, shell_bvh_gap(me, bg, pg)) + if best < 1e8: + board_gap = max(board_gap, best) + strap_gap = 0.0 + for sg, _ in straps: + best = 1e9 + for pg, _ in posts: + best = min(best, shell_bvh_gap(me, sg, pg)) + if best < 1e8: + strap_gap = max(strap_gap, best) + board_dy = max((a[4] - a[1] for _, a in boards), default=0.0) + shoe_z = min((a[2] for _, a in shoes), default=1.0) + return { + "posts": len(posts), + "boards": len(boards), + "shoes": len(shoes), + "straps": len(straps), + "board_gap": board_gap if boards and posts else 1e9, + "strap_gap": strap_gap if straps and posts else 1e9, + "board_dy": board_dy, + "shoe_z": shoe_z, + } + + def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None - img = bpy.data.images.new("SignpostNrm", size, size, alpha=True, float_buffer=False) + img = bpy.data.images.new("SignNrm", size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" nodes = target_mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") @@ -507,7 +661,8 @@ def bake_normal(high, low): scene.cycles.device = "CPU" scene.cycles.samples = 1 scene.cycles.use_denoising = False - deselect_all() + 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 @@ -525,7 +680,8 @@ def bake_normal(high, low): def export_unity(path, objects): - deselect_all() + 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] @@ -539,16 +695,46 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_shoe=False, + gap_board=False, + float_strap=False, + yaw_boards=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2) - high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4) - wood = principled("SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62) - board = principled("SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52) - metal = principled("SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38) + kw = dict( + short_shoe=short_shoe, + gap_board=gap_board, + float_strap=float_strap, + yaw_boards=yaw_boards, + ) + low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2, **kw) + high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4, **kw) + wood = principled( + "SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62, + noise_scale=14.0, wear=(0.22, 0.10, 0.04, 1.0), + ) + board = principled( + "SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52, + noise_scale=10.0, wear=(0.55, 0.38, 0.14, 1.0), + ) + metal = principled( + "SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38, + noise_scale=18.0, wear=(0.22, 0.18, 0.14, 1.0), + ) assign_slots(low, wood, board, metal) assign_slots(high, wood, board, metal) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += 0.05 + low.data.update() + if low.data is None or len(low.data.polygons) < 6: return fail("signpost mesh did not build", 3), None, None, None, None, None @@ -580,7 +766,7 @@ def check(skip_decimate): r2 = lod2_tris / base_tris if base_tris else 0.0 collider_src = build_signpost_mesh( - "SignpostColSrc", bevel_offset=0.0, bevel_segments=1 + "SignpostColSrc", bevel_offset=0.0, bevel_segments=1, **kw ) collider = convex_hull_collider(collider_src, "SignpostCollider") bpy.data.objects.remove(collider_src, do_unlink=True) @@ -595,6 +781,10 @@ def check(skip_decimate): export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + jnt = joint_audit(low.data) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " @@ -612,6 +802,17 @@ def check(skip_decimate): 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 posts={jnt['posts']} boards={jnt['boards']} " + f"shoes={jnt['shoes']} straps={jnt['straps']} " + f"board_gap={jnt['board_gap']:.5f} strap_gap={jnt['strap_gap']:.5f} " + f"board_dy={jnt['board_dy']:.4f} shoe_z={jnt['shoe_z']:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -648,6 +849,32 @@ def check(skip_decimate): f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7, ), None, None, None, None, None + if ( + bb[2] > ZMIN_EPS + or jnt["shoes"] < SHOE_COUNT + or jnt["shoe_z"] > SHOE_ZMIN_MAX + ): + return fail( + f"grounded zmin={bb[2]:.5f} shoes={jnt['shoes']} " + f"shoe_z={jnt['shoe_z']:.5f}", + 16, + ), None, None, None, None, None + if jnt["boards"] < 2 or jnt["posts"] < 1 or jnt["board_gap"] > BOARD_GAP_MAX: + return fail( + f"board gap {jnt['board_gap']:.5f} boards={jnt['boards']} " + f"posts={jnt['posts']}", + 17, + ), None, None, None, None, None + if jnt["straps"] < 1 or jnt["strap_gap"] > STRAP_GAP_MAX: + return fail( + f"strap gap {jnt['strap_gap']:.5f} straps={jnt['straps']}", + 18, + ), None, None, None, None, None + if jnt["board_dy"] > BOARD_DY_MAX: + return fail( + f"board dy {jnt['board_dy']:.4f} > {BOARD_DY_MAX}", + 19, + ), None, None, None, None, None if ( abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL @@ -681,6 +908,16 @@ def check(skip_decimate): ), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}", + 15, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -702,7 +939,6 @@ def render_still(low, wood, tex, path, engine): ob.hide_viewport = True low.rotation_euler.z = math.radians(-12.0) - low.rotation_euler.x = math.radians(0.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -794,14 +1030,24 @@ def main(): 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", - help="falsification: skip the LOD DECIMATE stage", - ) + 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("--short-shoe", action="store_true") + p.add_argument("--gap-board", action="store_true") + p.add_argument("--float-strap", action="store_true") + p.add_argument("--yaw-boards", action="store_true") args = p.parse_args(argv) - code, low, _high, wood, tex, _col = check(args.skip_decimate) + code, low, _high, wood, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + short_shoe=args.short_shoe, + gap_board=args.gap_board, + float_strap=args.float_strap, + yaw_boards=args.yaw_boards, + ) if code: return code if args.output: