diff --git a/README.md b/README.md index bc8fc3d7..392862c5 100644 --- a/README.md +++ b/README.md @@ -143,13 +143,13 @@ 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. [`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/signpost-hero.webp b/docs/gallery/assets/signpost-hero.webp index b2727e05..c8c1ef4e 100644 Binary files a/docs/gallery/assets/signpost-hero.webp and b/docs/gallery/assets/signpost-hero.webp differ diff --git a/docs/gallery/assets/signpost-preview.webp b/docs/gallery/assets/signpost-preview.webp new file mode 100644 index 00000000..3724acb3 Binary files /dev/null and b/docs/gallery/assets/signpost-preview.webp differ diff --git a/docs/gallery/assets/wall-torch-hero.webp b/docs/gallery/assets/wall-torch-hero.webp index cb700f3c..a790e58b 100644 Binary files a/docs/gallery/assets/wall-torch-hero.webp and b/docs/gallery/assets/wall-torch-hero.webp differ diff --git a/docs/gallery/assets/wall-torch-preview.webp b/docs/gallery/assets/wall-torch-preview.webp new file mode 100644 index 00000000..8b4c01f8 Binary files /dev/null and b/docs/gallery/assets/wall-torch-preview.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 9e644bee..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
@@ -1123,7 +1123,7 @@

wooden-bucket

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/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/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/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` diff --git a/showcase/gallery.json b/showcase/gallery.json index 158a1a46..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": [ @@ -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/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 266e72f1..c8c1ef4e 100644 Binary files a/showcase/signpost/preview.webp and b/showcase/signpost/preview.webp differ 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: 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 5e0d8556..a790e58b 100644 Binary files a/showcase/wall-torch/preview.webp and b/showcase/wall-torch/preview.webp differ 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: