From 23b1f1a27373115ced26608727ed3eea3ad8cdca Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:28:36 -0400 Subject: [PATCH 1/2] fix(showcase): rebuild anvil as London-pattern iron on a coopered stump The old piece stacked beveled boxes with glued nubs as holes and a twisted horn. One slotted face, oval rings, and stave-chord hoops are durable; per-coordinate nudges are not. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- docs/gallery/anvil/index.html | 1182 +++++++++++++++++++-------- docs/gallery/assets/anvil-hero.webp | Bin 8758 -> 26766 bytes showcase/anvil/README.md | 92 ++- showcase/anvil/anvil.py | 1152 ++++++++++++++++++-------- showcase/anvil/preview.webp | Bin 8232 -> 26766 bytes 5 files changed, 1758 insertions(+), 668 deletions(-) diff --git a/docs/gallery/anvil/index.html b/docs/gallery/anvil/index.html index 8f1f41d..c5f1bed 100644 --- a/docs/gallery/anvil/index.html +++ b/docs/gallery/anvil/index.html @@ -253,28 +253,42 @@

anvil

anvil render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 6616 tris, two materials with 2616 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.772×0.419×0.662 m, LOD ratios in band, convex collider 190 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 2988 tris, two materials with 1040 wood and 822 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.621×0.414×0.489 m, LOD ratios in band, convex collider 134 tris, hygiene 0, 16 grounded staves, hoop bite 0.003 m, foot-on-head gap 0.003 m, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-staves 16; --float-anvil 17; --round-band 18.
blender --background --python showcase/anvil/anvil.py --
-

A showcase piece, not an example. Procedural London-pattern anvil on a timber stump (horn, face, heel, waist, iron hoops) 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 London-pattern anvil on a coopered timber stump (16 jittered staves, chord-lofted iron hoops, a sawn head, spreading foot, pinched waist, one slotted face with hardy and pritchel through-holes, oval-lofted horn) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

The face is one add_slotted_slab so hardy/pritchel are real holes in a single shell, not nubs glued onto stacked boxes. Hoops evaluate the same host_outer_r chord as the stave flats. --round-band is the falsifier: a circle of radius_at(z) instead of the chord.

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

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 | 6450–6800 | 6616 / 6616 / 6616 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 628 wood, 2616 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.772, 0.419, 0.662) m ± 0.01 | (0.7720, 0.4183, 0.6618), zmin 0 | | Collider tris | ≤ 220 | 190 | | Export | written, size > 0 | 474984 / 474984 / 474972 bytes |

-

DECIMATE COLLAPSE triangle counts are not identical across series in general — the gate is a ratio band, not an exact count. On this mesh LOD ratios matched across 4.5.11 / 5.1.2 / 5.2.1. 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 | 2500–4500 | 2988 / 2988 / 2988 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2195 / 0.2195 / 0.2195 | | Materials | exactly 2 distinct; ≥400 wood, ≥400 metal faces | 2 slots; 1040 / 822 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.621, 0.414, 0.489) m ± 0.015 | (0.6210, 0.4137, 0.4890), zmin 0 | | Collider tris | ≤ 280 | 134 | | Export | written, size > 0 | 244052 / 244052 / 244036 bytes |

+

Base triangles dropped from 6616 to 2988 in the quality pass: overlapping face boxes and a beveled superellipse horn became one slotted slab plus a circular oval loft. Hidden coincident faces were the waste.

+

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. Stave-width jitter uses fixed seed 17. Export byte counts differ by 16 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 supports: 16 staves | each zmin ≤ 0.001 | 16, stave_z 0.00000 | | Body plan | 0.621 m long × 0.180 m high ± 0.05 | 0.6210 × 0.1800 |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Foot-on-head BVH gap | ≤ 0.006 m | 0.00300 | | Hoop bite (host r − inner hoop r) | 0.0012–0.008 m | 0.00300 |

+

Falsifiers

+

Each violates one named budget. All six 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-staves | named stave supports at Z=0 | 16 | | --float-anvil | foot-on-head gap | 17 | | --round-band | hoop bite band | 18 |

Run

blender --background --python anvil.py --
 blender --background --python anvil.py -- --skip-decimate
+blender --background --python anvil.py -- --stray-vert
+blender --background --python anvil.py -- --lift-z
+blender --background --python anvil.py -- --short-staves
+blender --background --python anvil.py -- --float-anvil
+blender --background --python anvil.py -- --round-band
 blender --background --python anvil.py -- --output anvil.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 ≠ 2 distinct slots, or wood/metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 1519 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 ≠ 2 distinct slots, or a face-count floor missed | | 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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named stave floats | | 17 | Joint fit: foot-on-head gap (--float-anvil) | | 18 | Seat: hoop bite band (--round-band) | | 19 | Body length or height off the stated real-world size |

Source

@@ -285,15 +299,22 @@

Source

"""Game-ready blacksmith anvil — a showcase piece, not an example.
 
 Asserts budget conformance of a procedural London-pattern anvil on a
-timber stump (horn, face, heel, waist, iron hoops) after composing
+coopered timber stump (stave-chord hoops, sawn head, horn/face/heel
+loft, hardy and pritchel through-holes, spreading foot) after composing
 shipped pipeline pieces: bmesh construction, UVs, two materials,
 high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
 
+The old piece was a 24-gon cone with circular tori floating off the
+flats, and a stack of beveled boxes with nubs glued on as 'holes'.
+
 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.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--short-staves`` named stave
+supports, ``--float-anvil`` foot-on-head joint-fit, ``--round-band``
+hoop seat (hoop generated on a circle instead of the stave chords).
 
-No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+Fixed seed 17 for stave-width jitter. DECIMATE COLLAPSE triangle counts
 are not byte-identical across Blender versions — the LOD gate is a
 ratio band, not an exact count.
 
@@ -304,13 +325,15 @@ 

Source

import argparse import math import os +import random import sys import tempfile import traceback import bmesh import bpy -from mathutils import Euler, Vector +from mathutils import Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -319,18 +342,59 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -STUMP_R = 0.20 -STUMP_H = 0.33 -FACE_L = 0.42 -FACE_W = 0.118 -FACE_T = 0.046 -HORN_L = 0.30 -HEEL_L = 0.12 - -BBOX_TOL = 0.01 -OUTER_SIZE = (0.772, 0.419, 0.662) -BASE_TRIS_MIN = 6450 -BASE_TRIS_MAX = 6800 +N_STAVES = 16 +STAVE_THICK = 0.028 +STAVE_SEED = 17 +STAVE_JITTER = 0.08 +GAP_M = 0.0012 +STUMP_H = 0.320 +R_BOT = 0.205 +R_TOP = 0.172 +N_RINGS = 6 +HOOP_ZS = (0.052, 0.268) +HOOP_H = 0.024 +HOOP_PROUD = 0.006 +HOOP_BITE = 0.003 +HOOP_CHAMFER = 0.0025 +HOOP_BITE_MIN = 0.0012 +HOOP_BITE_MAX = 0.008 +HEAD_T = 0.022 +CHIME = 0.008 +HEAD_GAP_MAX = 0.008 +STAVE_ZMIN_MAX = 0.001 +SHORT_STAVES_LIFT = 0.045 +LIFT_Z = 0.05 +FLOAT_ANVIL = 0.010 +FOOT_BITE = 0.003 +SEAT_GAP_MAX = 0.006 + +FACE_T = 0.050 +FACE_W = 0.112 +FACE_LEN = 0.280 +TABLE_DROP = 0.014 +TABLE_LEN = 0.046 +HORN_LEN = 0.220 +HEEL_LEN = 0.095 +FOOT_H = 0.030 +FOOT_XY = (0.210, 0.124) +WAIST_H = 0.100 +HARDY_HALF = 0.015 +PRITCHEL_R = 0.007 +N_SECTION = 16 + +AREA_EPS = 1e-10 +DOUBLES_EPS = 1e-5 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +BODY_TOL = 0.05 +BODY_LEN = 0.621 +BODY_H = 0.180 +BBOX_TOL = 0.015 +OUTER_SIZE = (0.621, 0.414, 0.489) + +BASE_TRIS_MIN = 2500 +BASE_TRIS_MAX = 4500 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -338,13 +402,14 @@

Source

LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 2 +WOOD_FACES_MIN = 400 +METAL_FACES_MIN = 400 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 220 +COLLIDER_TRIS_MAX = 280 BAKE_RES = 256 -CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 -WOOD_FACES_MIN = 12 +CAGE_EXTRUSION = 0.06 +STAVE_COUNT = N_STAVES WOOD_IDX = 0 METAL_IDX = 1 @@ -375,116 +440,410 @@

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 radius_at(z): + t = max(0.0, min(1.0, z / STUMP_H)) + return R_BOT + (R_TOP - R_BOT) * t + + +def stave_spans(n, gap_ang, jitter, seed): + rng = random.Random(seed) + weights = [1.0 + rng.uniform(-jitter, jitter) for _ in range(n)] + total = sum(weights) + usable = 2.0 * math.pi - n * gap_ang + spans = [] + a = 0.0 + for w in weights: + width = usable * (w / total) + spans.append((a, a + width)) + a += width + gap_ang + return spans + + +def host_outer_r(u, z, spans, round_band): + r = radius_at(z) + if round_band: + return r + u = u % (2.0 * math.pi) + for a0, a1 in spans: + if a0 - 1e-9 <= u <= a1 + 1e-9: + mid = 0.5 * (a0 + a1) + half = 0.5 * (a1 - a0) + den = math.cos(u - mid) + if abs(den) < 1e-4: + return r * math.cos(half) + return r * math.cos(half) / den + return r + + +def loft_cyclic(bm, sections, mat_idx): + vert_rings = [[bm.verts.new(p) for p in s] for s in sections] + m = len(vert_rings) + n = len(vert_rings[0]) + faces = [] + for k in range(m): + a = vert_rings[k] + b = vert_rings[(k + 1) % m] + for i in range(n): + j = (i + 1) % n + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + faces.append(face) + return [v for ring in vert_rings for v in ring], faces + + +def loft_open(bm, rings, mat_idx, cap0=True, cap1=True): + vert_rings = [[bm.verts.new(p) for p in ring] for ring in rings] + n = len(vert_rings[0]) + faces = [] + for k in range(len(vert_rings) - 1): + a = vert_rings[k] + b = vert_rings[k + 1] + for i in range(n): + j = (i + 1) % n + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + faces.append(face) + if cap0: + fan_cap(bm, vert_rings[0], mat_idx, flip=True) + if cap1: + fan_cap(bm, vert_rings[-1], mat_idx, flip=False) + return [v for ring in vert_rings for v in ring], faces + + +def fan_cap(bm, ring, mat_idx, flip=False): + center = Vector((0.0, 0.0, 0.0)) + for v in ring: + center += v.co + center /= len(ring) + hub = bm.verts.new(center) + n = len(ring) + for i in range(n): + vs = (hub, ring[i], ring[(i + 1) % n]) + if flip: + vs = (hub, vs[2], vs[1]) + face = bm.faces.new(vs) + face.material_index = mat_idx + return hub + + +def croze_xy(spans, z, bite, shrink=0.0): + pts = [] + for a0, a1 in spans: + for u in (a0, 0.5 * (a0 + a1), a1): + r = host_outer_r(u, z, spans, False) - STAVE_THICK + bite - shrink + pts.append((r * math.cos(u), r * math.sin(u))) + return pts + + +def add_polygon_disk(bm, z0, z1, xy_ring, mat_idx): + rings = [] + for z in (z0, z1): + ring = [bm.verts.new((x, y, z)) for x, y in xy_ring] + rings.append(ring) + a, b = rings + n = len(xy_ring) + for i in range(n): + j = (i + 1) % n + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + fan_cap(bm, a, mat_idx, flip=True) + fan_cap(bm, b, mat_idx, flip=False) + + +def oval_ring(x, ry, rz, z_mid): + pts = [] + for i in range(N_SECTION): + t = i * (2.0 * math.pi / N_SECTION) + pts.append(Vector((x, ry * math.cos(t), z_mid + rz * math.sin(t)))) + return pts + + +def add_box_solid(bm, x0, x1, y0, y1, z0, z1, mat_idx): + corners = [ + (x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), + (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1), + ] + vs = [bm.verts.new(p) for p in corners] + faces = ( + (0, 3, 2, 1), + (4, 5, 6, 7), + (0, 1, 5, 4), + (1, 2, 6, 5), + (2, 3, 7, 6), + (3, 0, 4, 7), + ) + for idx in faces: + face = bm.faces.new(tuple(vs[i] for i in idx)) + face.material_index = mat_idx + return vs + + +def add_box_square_hole(bm, x0, x1, y0, y1, z0, z1, hx0, hx1, hy0, hy1, mat_idx): + add_slotted_slab(bm, x0, x1, y0, y1, z0, z1, [(hx0, hx1, hy0, hy1)], mat_idx) + + +def add_slotted_slab(bm, x0, x1, y0, y1, z0, z1, holes, mat_idx): + xs = [x0, x1] + ys = [y0, y1] + for hx0, hx1, hy0, hy1 in holes: + xs.extend((hx0, hx1)) + ys.extend((hy0, hy1)) + xs = sorted(set(round(v, 8) for v in xs)) + ys = sorted(set(round(v, 8) for v in ys)) + + def in_hole(cx0, cx1, cy0, cy1): + mx = 0.5 * (cx0 + cx1) + my = 0.5 * (cy0 + cy1) + for hx0, hx1, hy0, hy1 in holes: + if hx0 < mx < hx1 and hy0 < my < hy1: + return True + return False + + grid = {} + for zi, z in ((0, z0), (1, z1)): + for ix, x in enumerate(xs): + for iy, y in enumerate(ys): + grid[(ix, iy, zi)] = bm.verts.new((x, y, z)) + + def quad(a, b, c, d): + face = bm.faces.new((a, b, c, d)) + face.material_index = mat_idx + + nx, ny = len(xs), len(ys) + for ix in range(nx - 1): + for iy in range(ny - 1): + if in_hole(xs[ix], xs[ix + 1], ys[iy], ys[iy + 1]): + continue + a = grid[(ix, iy, 1)] + b = grid[(ix + 1, iy, 1)] + c = grid[(ix + 1, iy + 1, 1)] + d = grid[(ix, iy + 1, 1)] + quad(a, b, c, d) + a = grid[(ix, iy, 0)] + b = grid[(ix, iy + 1, 0)] + c = grid[(ix + 1, iy + 1, 0)] + d = grid[(ix + 1, iy, 0)] + quad(a, b, c, d) + + for ix in range(nx - 1): + quad( + grid[(ix, 0, 0)], grid[(ix + 1, 0, 0)], + grid[(ix + 1, 0, 1)], grid[(ix, 0, 1)], + ) + quad( + grid[(ix, ny - 1, 0)], grid[(ix, ny - 1, 1)], + grid[(ix + 1, ny - 1, 1)], grid[(ix + 1, ny - 1, 0)], + ) + for iy in range(ny - 1): + quad( + grid[(0, iy, 0)], grid[(0, iy, 1)], + grid[(0, iy + 1, 1)], grid[(0, iy + 1, 0)], + ) + quad( + grid[(nx - 1, iy, 0)], grid[(nx - 1, iy + 1, 0)], + grid[(nx - 1, iy + 1, 1)], grid[(nx - 1, iy, 1)], + ) + + for hx0, hx1, hy0, hy1 in holes: + ix0 = xs.index(round(hx0, 8)) + ix1 = xs.index(round(hx1, 8)) + iy0 = ys.index(round(hy0, 8)) + iy1 = ys.index(round(hy1, 8)) + for ix in range(ix0, ix1): + quad( + grid[(ix, iy0, 0)], grid[(ix, iy0, 1)], + grid[(ix + 1, iy0, 1)], grid[(ix + 1, iy0, 0)], + ) + quad( + grid[(ix, iy1, 0)], grid[(ix + 1, iy1, 0)], + grid[(ix + 1, iy1, 1)], grid[(ix, iy1, 1)], + ) + for iy in range(iy0, iy1): + quad( + grid[(ix0, iy, 0)], grid[(ix0, iy + 1, 0)], + grid[(ix0, iy + 1, 1)], grid[(ix0, iy, 1)], + ) + quad( + grid[(ix1, iy, 0)], grid[(ix1, iy, 1)], + grid[(ix1, iy + 1, 1)], grid[(ix1, iy + 1, 0)], + ) -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"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) - v.co = rot @ p + 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_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( +def build_hoops(bm, spans, round_band): + faces_all = [] + for z_mid in HOOP_ZS: + z0 = z_mid - HOOP_H * 0.5 + z1 = z_mid + HOOP_H * 0.5 + c = HOOP_CHAMFER + sections = [] + samples = [] + for a0, a1 in spans: + samples.append(a0) + samples.append(0.5 * (a0 + a1)) + samples.append(a1) + for u in samples: + cu, su = math.cos(u), math.sin(u) + r0 = host_outer_r(u, z0, spans, round_band) + r1 = host_outer_r(u, z1, spans, round_band) + profile = ( + (r0 - HOOP_BITE, z0), + (r0 + HOOP_PROUD - c, z0), + (r0 + HOOP_PROUD, z0 + c), + (r1 + HOOP_PROUD, z1 - c), + (r1 + HOOP_PROUD - c, z1), + (r1 - HOOP_BITE, z1), + ) + sections.append([Vector((r * cu, r * su, z)) for r, z in profile]) + _verts, faces = loft_cyclic(bm, sections, METAL_IDX) + faces_all.extend(faces) + return faces_all + + +def build_staves(bm, spans, z0, bevel_offset, bevel_segments): + zs = [STUMP_H * i / (N_RINGS - 1) for i in range(N_RINGS)] + stave_verts = [] + for a0, a1 in spans: + outer = [] + inner = [] + for z in zs: + r = radius_at(z) + ov = ( + bm.verts.new((r * math.cos(a0), r * math.sin(a0), z0 + z)), + bm.verts.new((r * math.cos(a1), r * math.sin(a1), z0 + z)), + ) + ri = r - STAVE_THICK + iv = ( + bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z0 + z)), + bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z0 + z)), + ) + outer.append(ov) + inner.append(iv) + stave_verts.extend(ov) + stave_verts.extend(iv) + for k in range(N_RINGS - 1): + o0a, o0b = outer[k] + o1a, o1b = outer[k + 1] + i0a, i0b = inner[k] + i1a, i1b = inner[k + 1] + for vs in ( + (o0a, o1a, o1b, o0b), + (i0b, i1b, i1a, i0a), + (o0a, i0a, i1a, o1a), + (o0b, o1b, i1b, i0b), + ): + face = bm.faces.new(vs) + face.material_index = WOOD_IDX + top = bm.faces.new((outer[-1][0], outer[-1][1], inner[-1][1], inner[-1][0])) + top.material_index = WOOD_IDX + bot = bm.faces.new((outer[0][1], outer[0][0], inner[0][0], inner[0][1])) + bot.material_index = WOOD_IDX + if bevel_offset > 0.0: + long_edges = [] + seen = set() + for v in stave_verts: + for e in v.link_edges: + if e in seen: + continue + seen.add(e) + a, b = e.verts + if abs(a.co.z - b.co.z) > 0.02: + long_edges.append(e) + if long_edges: + ret = bmesh.ops.bevel( + bm, + geom=long_edges, + offset=min(bevel_offset, 0.004), + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + return stave_verts + + +def build_anvil_iron(bm, z_head, float_anvil, bevel_offset, bevel_segments): + z_foot0 = z_head - FOOT_BITE + (FLOAT_ANVIL if float_anvil else 0.0) + z_foot1 = z_foot0 + FOOT_H + z_body0 = z_foot1 + WAIST_H + z_face = z_body0 + FACE_T + hy = FACE_W * 0.5 + x_heel = -FACE_LEN * 0.5 - HEEL_LEN + x_face0 = -FACE_LEN * 0.5 + x_hardy0 = x_face0 + 0.042 + x_hardy1 = x_hardy0 + 0.055 + x_prit0 = 0.055 + x_prit1 = x_prit0 + 0.048 + x_face1 = FACE_LEN * 0.5 + x_table1 = x_face1 + TABLE_LEN + x_horn1 = x_table1 + HORN_LEN - 0.020 + + pr_half = PRITCHEL_R + pr_cx = 0.5 * (x_prit0 + x_prit1) + add_slotted_slab( bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), + x_heel, x_face1, + -hy, hy, + z_body0, z_face, + [ + ( + x_hardy0 + 0.012, x_hardy1 - 0.012, + -HARDY_HALF, HARDY_HALF, + ), + ( + pr_cx - pr_half, pr_cx + pr_half, + -pr_half, pr_half, + ), + ], + METAL_IDX, ) - - -def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( + add_box_solid( bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius, - radius2=radius, - depth=depth, + x_face1 - 0.006, x_table1, + -hy * 0.88, hy * 0.88, + z_body0 + 0.004, z_face - TABLE_DROP, + METAL_IDX, ) - 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( + + z_mid = 0.5 * ((z_body0 + 0.004) + (z_face - TABLE_DROP)) + horn_rings = [ + oval_ring(x_table1 - 0.018, hy * 0.80, 0.018, z_mid), + oval_ring(x_table1 + 0.040, hy * 0.58, 0.015, z_mid - 0.004), + oval_ring(x_table1 + 0.095, hy * 0.34, 0.011, z_mid - 0.010), + oval_ring(x_table1 + 0.150, hy * 0.16, 0.008, z_mid - 0.016), + oval_ring(x_horn1, 0.011, 0.008, z_mid - 0.022), + ] + horn_verts, _faces = loft_open(bm, horn_rings, METAL_IDX, cap0=True, cap1=True) + + add_box_solid( bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + -FOOT_XY[0] * 0.5, FOOT_XY[0] * 0.5, + -FOOT_XY[1] * 0.5, FOOT_XY[1] * 0.5, + z_foot0, z_foot1 + 0.004, + METAL_IDX, ) - 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_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) + pinch_hy = 0.028 + pinch_hx = 0.055 + waist_mid = z_foot1 + WAIST_H * 0.48 + waist_z = (z_foot1, waist_mid, z_body0 + 0.002) + waist_hy = (FOOT_XY[1] * 0.46, pinch_hy, hy * 0.42) + waist_hx = (FOOT_XY[0] * 0.42, pinch_hx, FACE_LEN * 0.22) + real_waist = [] + for z, hx, hy_w in zip(waist_z, waist_hx, waist_hy): 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 + for i in range(N_SECTION): + t = i * (2.0 * math.pi / N_SECTION) + ring.append(Vector((hx * math.cos(t), hy_w * math.sin(t), z))) + real_waist.append(ring) + loft_open(bm, real_waist, METAL_IDX, cap0=True, cap1=True) + return z_foot0, z_face, list(horn_verts) + + +def triangulate_ngons(bm): + faces = [f for f in bm.faces if len(f.verts) > 4] + if faces: + bmesh.ops.triangulate(bm, faces=faces) def pack_uvs(bm, margin=0.08): @@ -530,174 +889,32 @@

Source

) -def build_anvil_mesh(name, bevel_offset, bevel_segments): +def build_anvil_mesh( + name, + bevel_offset, + bevel_segments, + short_staves=False, + float_anvil=False, + round_band=False, +): bm = bmesh.new() + gap_ang = GAP_M / R_BOT + spans = stave_spans(N_STAVES, gap_ang, STAVE_JITTER, STAVE_SEED) + z0 = SHORT_STAVES_LIFT if short_staves else 0.0 try: - wood = [] - metal = [] - - wood.extend( - add_cone( - bm, - (0.0, 0.0, STUMP_H / 2.0), - STUMP_R + 0.010, - STUMP_R - 0.018, - STUMP_H, - 24, - WOOD_IDX, - ) - ) - wood.extend( - add_cyl( - bm, - (0.0, 0.0, STUMP_H + 0.010), - STUMP_R - 0.022, - 0.020, - 24, - WOOD_IDX, - ) - ) - - if bevel_offset > 0.0: - edges = list({e for v in wood for e in v.link_edges}) - ret = bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - for f in ret.get("faces") or []: - f.material_index = WOOD_IDX - - for z in (0.048, STUMP_H - 0.042): - metal.extend( - add_rim(bm, (0.0, 0.0, z), STUMP_R + 0.004, 0.011, METAL_IDX) - ) - - z0 = STUMP_H - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.028), - (0.44, 0.20, 0.056), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.078), - (0.30, 0.130, 0.046), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.155), - (0.150, 0.058, 0.112), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.236), - (0.36, 0.108, 0.050), - METAL_IDX, - ) - ) - face_z = z0 + 0.236 + 0.025 + FACE_T / 2.0 - metal.extend( - add_box( - bm, - (0.0, 0.0, face_z), - (FACE_L, FACE_W, FACE_T), - METAL_IDX, - ) - ) - - horn_z = face_z - 0.006 - metal.extend( - add_cone( - bm, - (FACE_L / 2.0 + HORN_L / 2.0 - 0.050, 0.0, horn_z), - FACE_W * 0.46, - 0.012, - HORN_L, - 20, - METAL_IDX, - euler=(0.0, math.pi / 2.0, 0.0), - ) - ) - metal.extend( - add_box( - bm, - (FACE_L / 2.0 - 0.010, 0.0, horn_z - 0.014), - (0.12, FACE_W * 0.88, 0.040), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (-FACE_L / 2.0 - HEEL_L / 2.0 + 0.018, 0.0, face_z - 0.004), - (HEEL_L, FACE_W * 0.92, FACE_T * 0.82), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (-FACE_L * 0.22, 0.0, face_z + FACE_T / 2.0 - 0.005), - (0.028, 0.028, 0.012), - METAL_IDX, - ) - ) - metal.extend( - add_cyl( - bm, - (FACE_L * 0.22, 0.0, face_z + FACE_T / 2.0 - 0.004), - 0.008, - 0.012, - 12, - METAL_IDX, - ) + build_staves(bm, spans, z0, bevel_offset, bevel_segments) + head_z0 = z0 + STUMP_H - CHIME - HEAD_T + head_z1 = z0 + STUMP_H - CHIME + add_polygon_disk( + bm, head_z0, head_z1, + croze_xy(spans, 0.5 * (head_z0 + head_z1), 0.004, 0.0), + WOOD_IDX, ) - - xs = [v.co.x for v in bm.verts] - ys = [v.co.y for v in bm.verts] - 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 - - if bevel_offset > 0.0: - metal_edges = list( - {e for v in metal for e in v.link_edges if v.is_valid} - ) - if metal_edges: - ret = bmesh.ops.bevel( - bm, - geom=metal_edges, - offset=max(bevel_offset, 0.012), - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - for f in ret.get("faces") or []: - f.material_index = METAL_IDX - + build_anvil_iron(bm, head_z1, float_anvil, bevel_offset, bevel_segments) + build_hoops(bm, spans, round_band) + bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5) + bmesh.ops.dissolve_degenerate(bm, dist=1e-6) + triangulate_ngons(bm) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: @@ -705,25 +922,45 @@

Source

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(38.0): + if edge.calc_face_angle() > math.radians(35.0): edge.smooth = False me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() finally: bm.free() - out = bpy.data.objects.new(name, me) - bpy.context.collection.objects.link(out) - return out + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj -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 = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + 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"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.18) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat @@ -745,6 +982,17 @@

Source

return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) +def face_area(me, poly): + verts = [me.vertices[i].co for i in poly.vertices] + if len(verts) < 3: + return 0.0 + acc = Vector((0.0, 0.0, 0.0)) + origin = verts[0] + for a, b in zip(verts[1:], verts[2:]): + acc += (a - origin).cross(b - origin) + return 0.5 * acc.length + + def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: @@ -770,11 +1018,210 @@

Source

return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +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) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + 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 p in me.polygons: + if all(i in member for i in p.vertices): + return p.material_index + return None + + +def support_audit(me): + groups = shells(me) + staves = [] + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dz = a[5] - a[2] + if dz > 0.20: + staves.append(a) + stave_z = min((a[2] for a in staves), default=99.0) + return {"staves": len(staves), "stave_z": stave_z} + + +def hoop_seat(me, spans): + groups = shells(me) + bites = [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dz > 0.06 or max(dx, dy) < 0.28: + continue + rs = [math.hypot(me.vertices[i].co.x, me.vertices[i].co.y) for i in g] + if min(rs) < R_TOP * 0.6: + continue + for i, r in zip(g, rs): + p = me.vertices[i].co + u = math.atan2(p.y, p.x) + host = host_outer_r(u, p.z, spans, False) + if r > host + 0.0005: + continue + bites.append(host - r) + if not bites: + return 0.05, 0.05 + return min(bites), max(bites) + + +def anvil_seat(me): + groups = shells(me) + woods, metals = [], [] + for g in groups: + a = shell_aabb(me, g) + mat = mat_of(me, g) + dz = a[5] - a[2] + if mat == WOOD_IDX and dz < 0.08 and a[2] > STUMP_H * 0.6: + woods.append(g) + elif mat == METAL_IDX: + radial = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if a[5] < STUMP_H * 0.95 and radial > 0.15: + continue + metals.append((a[2], g)) + if not woods or not metals: + return 99.0 + metals.sort() + foots = [metals[0][1]] + bm_s = bmesh.new() + try: + bm_s.from_mesh(me) + keep = set() + for g in woods: + keep.update(g) + drop = [f for f in bm_s.faces if not all(v.index in keep for v in f.verts)] + if drop: + bmesh.ops.delete(bm_s, geom=drop, context="FACES") + if not bm_s.faces: + return 99.0 + tree = BVHTree.FromBMesh(bm_s) + worst = 0.0 + for g in foots: + zmin = min(me.vertices[i].co.z for i in g) + for i in g: + p = me.vertices[i].co + if p.z > zmin + 0.006: + continue + loc, _n, _i, dist = tree.find_nearest(p) + if loc is None: + continue + worst = max(worst, dist) + return worst + finally: + bm_s.free() + + +def body_plan(me): + groups = shells(me) + xs, z0s, z1s = [], [], [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + radial = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if a[5] < STUMP_H * 0.95 and radial > 0.15: + continue + xs.extend((a[0], a[3])) + z0s.append(a[2]) + z1s.append(a[5]) + if not xs: + return 0.0, 0.0 + return max(xs) - min(xs), max(z1s) - min(z0s) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, STUMP_H * 0.5)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) lod.matrix_world = obj.matrix_world.copy() - bpy.context.scene.collection.objects.link(lod) + bpy.context.collection.objects.link(lod) if not skip_decimate and 0.0 < ratio < 1.0: mod = lod.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" @@ -794,6 +1241,14 @@

Source

bmesh.ops.delete(bm, geom=interior, context="VERTS") if unused: bmesh.ops.delete(bm, geom=unused, context="VERTS") + bmesh.ops.dissolve_limit( + bm, + angle_limit=math.radians(10.0), + verts=list(bm.verts), + edges=list(bm.edges), + delimit={"NORMAL"}, + ) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(mesh) mesh.update() finally: @@ -824,7 +1279,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 @@ -842,7 +1298,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] @@ -856,18 +1313,40 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_staves=False, + float_anvil=False, + round_band=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_anvil_mesh("AnvilLow", bevel_offset=0.010, bevel_segments=2) - high = build_anvil_mesh("AnvilHigh", bevel_offset=0.010, bevel_segments=4) - wood = principled("AnvilStump", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62) - metal = principled("AnvilSteel", (0.16, 0.15, 0.14, 1.0), 0.90, 0.34) + flags = dict(short_staves=short_staves, float_anvil=float_anvil, round_band=round_band) + low = build_anvil_mesh("AnvilLow", 0.004, 2, **flags) + high = build_anvil_mesh("AnvilHigh", 0.004, 4, **flags) + wood = principled( + "AnvilStump", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62, + noise_scale=6.5, wear=(0.22, 0.12, 0.05, 1.0), + ) + metal = principled( + "AnvilSteel", (0.18, 0.18, 0.19, 1.0), 0.92, 0.32, + noise_scale=5.5, wear=(0.10, 0.10, 0.11, 1.0), + ) assign_slots(low, wood, metal) assign_slots(high, wood, metal) + 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() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("anvil mesh did not build", 3), None, None, None, None, None + spans = stave_spans(N_STAVES, GAP_M / R_BOT, STAVE_JITTER, STAVE_SEED) base_tris = triangle_count(low.data) mats = [s for s in low.data.materials if s is not None] nmat = len(mats) @@ -881,6 +1360,22 @@

Source

size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + bite_min, bite_max = hoop_seat(low.data, spans) + seat = anvil_seat(low.data) + blen, bht = body_plan(low.data) + 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 staves={sup['staves']} stave_z={sup['stave_z']:.5f} " + f"hoop_bite={bite_min:.5f}..{bite_max:.5f} seat={seat:.5f} " + f"body={blen:.4f}x{bht:.4f}" + ) img, tex = setup_bake_image(low, wood) if img is None: @@ -895,7 +1390,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_anvil_mesh("AnvilColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_anvil_mesh("AnvilColSrc", 0.0, 1) collider = convex_hull_collider(collider_src, "AnvilCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -990,21 +1485,40 @@

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 + if bb[2] > ZMIN_EPS or sup["staves"] != STAVE_COUNT or sup["stave_z"] > STAVE_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} staves={sup['staves']} " + f"stave_z={sup['stave_z']:.5f}", + 16, + ), None, None, None, None, None + if seat > SEAT_GAP_MAX: + return fail(f"anvil seat gap {seat:.5f} > {SEAT_GAP_MAX}", 17), None, None, None, None, None + if bite_min < HOOP_BITE_MIN or bite_max > HOOP_BITE_MAX: + return fail( + f"hoop bite {bite_min:.5f}..{bite_max:.5f} " + f"not in [{HOOP_BITE_MIN}, {HOOP_BITE_MAX}]", + 18, + ), None, None, None, None, None + if abs(blen - BODY_LEN) > BODY_TOL or abs(bht - BODY_H) > BODY_TOL: + return fail( + f"anvil body {blen:.4f}x{bht:.4f} off {BODY_LEN}x{BODY_H}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider -def wire_normal(mat, tex): - nt = mat.node_tree - bsdf = nt.nodes["Principled BSDF"] - nrm = nt.nodes.new("ShaderNodeNormalMap") - nrm.inputs["Strength"].default_value = 1.0 - nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) - nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) - - -def render_still(low, wood, tex, path, engine): +def render_still(low, _wood, _tex, path, engine): scene = bpy.context.scene - wire_normal(wood, tex) for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True @@ -1054,13 +1568,15 @@

Source

light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.32, -1.90, 1.04) + cam.location = (span * 1.61, -span * 2.35, span * 1.32) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.44) + aim.location = (0.02 * span, 0.0, 0.5 * (bb[2] + bb[5]) + 0.04 * span) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1103,14 +1619,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("--short-staves", action="store_true") + p.add_argument("--float-anvil", action="store_true") + p.add_argument("--round-band", 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_staves=args.short_staves, + float_anvil=args.float_anvil, + round_band=args.round_band, + ) if code: return code if args.output: diff --git a/docs/gallery/assets/anvil-hero.webp b/docs/gallery/assets/anvil-hero.webp index d9cfb049946c7edbf08d4f35f04001a0a85746ab..39b42162496f1e5890578c2fe8b4257c0aedcaf8 100644 GIT binary patch literal 26766 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 B>hS;o literal 8758 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 0 | 474984 / 474984 / 474972 bytes | +| Outer AABB | (0.621, 0.414, 0.489) m ± 0.015 | (0.6210, 0.4137, 0.4890), zmin 0 | +| Collider tris | ≤ 280 | 134 | +| Export | written, size > 0 | 244052 / 244052 / 244036 bytes | + +Base triangles dropped from **6616 to 2988** in the quality pass: +overlapping face boxes and a beveled superellipse horn became one +slotted slab plus a circular oval loft. Hidden coincident faces were +the waste. + +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. Stave-width jitter uses fixed seed 17. Export byte counts +differ by 16 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 supports: 16 staves | each `zmin` ≤ 0.001 | 16, stave_z 0.00000 | +| Body plan | 0.621 m long × 0.180 m high ± 0.05 | 0.6210 × 0.1800 | + +### Joint fit and seat + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Foot-on-head BVH gap | ≤ 0.006 m | 0.00300 | +| Hoop bite (host r − inner hoop r) | 0.0012–0.008 m | 0.00300 | + +### Falsifiers -DECIMATE COLLAPSE triangle counts are **not** identical across series in -general — the gate is a ratio band, not an exact count. On this mesh LOD -ratios matched across 4.5.11 / 5.1.2 / 5.2.1. 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. +Each violates one named budget. All six were run on 4.5.11, 5.1.2 and +5.2.1 and returned the same code on each. -`--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. +| 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-staves` | named stave supports at Z=0 | 16 | +| `--float-anvil` | foot-on-head gap | 17 | +| `--round-band` | hoop bite band | 18 | ## Run ```bash blender --background --python anvil.py -- blender --background --python anvil.py -- --skip-decimate +blender --background --python anvil.py -- --stray-vert +blender --background --python anvil.py -- --lift-z +blender --background --python anvil.py -- --short-staves +blender --background --python anvil.py -- --float-anvil +blender --background --python anvil.py -- --round-band blender --background --python anvil.py -- --output anvil.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 | | --- | --- | @@ -62,7 +113,7 @@ File-local. `9` is a valid check code. `10` is reserved for | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | -| 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing | +| 5 | Material count ≠ 2 distinct slots, or a face-count floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | @@ -72,3 +123,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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | +| 16 | Not grounded: bounding box `zmin` off 0, or a named stave floats | +| 17 | Joint fit: foot-on-head gap (`--float-anvil`) | +| 18 | Seat: hoop bite band (`--round-band`) | +| 19 | Body length or height off the stated real-world size | diff --git a/showcase/anvil/anvil.py b/showcase/anvil/anvil.py index d88d1cc..d32befb 100644 --- a/showcase/anvil/anvil.py +++ b/showcase/anvil/anvil.py @@ -1,15 +1,22 @@ """Game-ready blacksmith anvil — a showcase piece, not an example. Asserts budget conformance of a procedural London-pattern anvil on a -timber stump (horn, face, heel, waist, iron hoops) after composing +coopered timber stump (stave-chord hoops, sawn head, horn/face/heel +loft, hardy and pritchel through-holes, spreading foot) after composing shipped pipeline pieces: bmesh construction, UVs, two materials, high-to-low normal bake, LOD chain, convex collider, Unity glTF export. +The old piece was a 24-gon cone with circular tori floating off the +flats, and a stack of beveled boxes with nubs glued on as 'holes'. + 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. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-staves`` named stave +supports, ``--float-anvil`` foot-on-head joint-fit, ``--round-band`` +hoop seat (hoop generated on a circle instead of the stave chords). -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts +Fixed seed 17 for stave-width jitter. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a ratio band, not an exact count. @@ -20,13 +27,15 @@ import argparse import math import os +import random import sys import tempfile import traceback import bmesh import bpy -from mathutils import Euler, Vector +from mathutils import Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -35,18 +44,59 @@ sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -STUMP_R = 0.20 -STUMP_H = 0.33 -FACE_L = 0.42 -FACE_W = 0.118 -FACE_T = 0.046 -HORN_L = 0.30 -HEEL_L = 0.12 - -BBOX_TOL = 0.01 -OUTER_SIZE = (0.772, 0.419, 0.662) -BASE_TRIS_MIN = 6450 -BASE_TRIS_MAX = 6800 +N_STAVES = 16 +STAVE_THICK = 0.028 +STAVE_SEED = 17 +STAVE_JITTER = 0.08 +GAP_M = 0.0012 +STUMP_H = 0.320 +R_BOT = 0.205 +R_TOP = 0.172 +N_RINGS = 6 +HOOP_ZS = (0.052, 0.268) +HOOP_H = 0.024 +HOOP_PROUD = 0.006 +HOOP_BITE = 0.003 +HOOP_CHAMFER = 0.0025 +HOOP_BITE_MIN = 0.0012 +HOOP_BITE_MAX = 0.008 +HEAD_T = 0.022 +CHIME = 0.008 +HEAD_GAP_MAX = 0.008 +STAVE_ZMIN_MAX = 0.001 +SHORT_STAVES_LIFT = 0.045 +LIFT_Z = 0.05 +FLOAT_ANVIL = 0.010 +FOOT_BITE = 0.003 +SEAT_GAP_MAX = 0.006 + +FACE_T = 0.050 +FACE_W = 0.112 +FACE_LEN = 0.280 +TABLE_DROP = 0.014 +TABLE_LEN = 0.046 +HORN_LEN = 0.220 +HEEL_LEN = 0.095 +FOOT_H = 0.030 +FOOT_XY = (0.210, 0.124) +WAIST_H = 0.100 +HARDY_HALF = 0.015 +PRITCHEL_R = 0.007 +N_SECTION = 16 + +AREA_EPS = 1e-10 +DOUBLES_EPS = 1e-5 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +BODY_TOL = 0.05 +BODY_LEN = 0.621 +BODY_H = 0.180 +BBOX_TOL = 0.015 +OUTER_SIZE = (0.621, 0.414, 0.489) + +BASE_TRIS_MIN = 2500 +BASE_TRIS_MAX = 4500 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -54,13 +104,14 @@ LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 2 +WOOD_FACES_MIN = 400 +METAL_FACES_MIN = 400 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 220 +COLLIDER_TRIS_MAX = 280 BAKE_RES = 256 -CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 -WOOD_FACES_MIN = 12 +CAGE_EXTRUSION = 0.06 +STAVE_COUNT = N_STAVES WOOD_IDX = 0 METAL_IDX = 1 @@ -91,116 +142,410 @@ 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 radius_at(z): + t = max(0.0, min(1.0, z / STUMP_H)) + return R_BOT + (R_TOP - R_BOT) * t + + +def stave_spans(n, gap_ang, jitter, seed): + rng = random.Random(seed) + weights = [1.0 + rng.uniform(-jitter, jitter) for _ in range(n)] + total = sum(weights) + usable = 2.0 * math.pi - n * gap_ang + spans = [] + a = 0.0 + for w in weights: + width = usable * (w / total) + spans.append((a, a + width)) + a += width + gap_ang + return spans + + +def host_outer_r(u, z, spans, round_band): + r = radius_at(z) + if round_band: + return r + u = u % (2.0 * math.pi) + for a0, a1 in spans: + if a0 - 1e-9 <= u <= a1 + 1e-9: + mid = 0.5 * (a0 + a1) + half = 0.5 * (a1 - a0) + den = math.cos(u - mid) + if abs(den) < 1e-4: + return r * math.cos(half) + return r * math.cos(half) / den + return r + + +def loft_cyclic(bm, sections, mat_idx): + vert_rings = [[bm.verts.new(p) for p in s] for s in sections] + m = len(vert_rings) + n = len(vert_rings[0]) + faces = [] + for k in range(m): + a = vert_rings[k] + b = vert_rings[(k + 1) % m] + for i in range(n): + j = (i + 1) % n + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + faces.append(face) + return [v for ring in vert_rings for v in ring], faces + + +def loft_open(bm, rings, mat_idx, cap0=True, cap1=True): + vert_rings = [[bm.verts.new(p) for p in ring] for ring in rings] + n = len(vert_rings[0]) + faces = [] + for k in range(len(vert_rings) - 1): + a = vert_rings[k] + b = vert_rings[k + 1] + for i in range(n): + j = (i + 1) % n + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + faces.append(face) + if cap0: + fan_cap(bm, vert_rings[0], mat_idx, flip=True) + if cap1: + fan_cap(bm, vert_rings[-1], mat_idx, flip=False) + return [v for ring in vert_rings for v in ring], faces + + +def fan_cap(bm, ring, mat_idx, flip=False): + center = Vector((0.0, 0.0, 0.0)) + for v in ring: + center += v.co + center /= len(ring) + hub = bm.verts.new(center) + n = len(ring) + for i in range(n): + vs = (hub, ring[i], ring[(i + 1) % n]) + if flip: + vs = (hub, vs[2], vs[1]) + face = bm.faces.new(vs) + face.material_index = mat_idx + return hub + + +def croze_xy(spans, z, bite, shrink=0.0): + pts = [] + for a0, a1 in spans: + for u in (a0, 0.5 * (a0 + a1), a1): + r = host_outer_r(u, z, spans, False) - STAVE_THICK + bite - shrink + pts.append((r * math.cos(u), r * math.sin(u))) + return pts + + +def add_polygon_disk(bm, z0, z1, xy_ring, mat_idx): + rings = [] + for z in (z0, z1): + ring = [bm.verts.new((x, y, z)) for x, y in xy_ring] + rings.append(ring) + a, b = rings + n = len(xy_ring) + for i in range(n): + j = (i + 1) % n + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + fan_cap(bm, a, mat_idx, flip=True) + fan_cap(bm, b, mat_idx, flip=False) + + +def oval_ring(x, ry, rz, z_mid): + pts = [] + for i in range(N_SECTION): + t = i * (2.0 * math.pi / N_SECTION) + pts.append(Vector((x, ry * math.cos(t), z_mid + rz * math.sin(t)))) + return pts + + +def add_box_solid(bm, x0, x1, y0, y1, z0, z1, mat_idx): + corners = [ + (x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), + (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1), + ] + vs = [bm.verts.new(p) for p in corners] + faces = ( + (0, 3, 2, 1), + (4, 5, 6, 7), + (0, 1, 5, 4), + (1, 2, 6, 5), + (2, 3, 7, 6), + (3, 0, 4, 7), + ) + for idx in faces: + face = bm.faces.new(tuple(vs[i] for i in idx)) + face.material_index = mat_idx + return vs + + +def add_box_square_hole(bm, x0, x1, y0, y1, z0, z1, hx0, hx1, hy0, hy1, mat_idx): + add_slotted_slab(bm, x0, x1, y0, y1, z0, z1, [(hx0, hx1, hy0, hy1)], mat_idx) + + +def add_slotted_slab(bm, x0, x1, y0, y1, z0, z1, holes, mat_idx): + xs = [x0, x1] + ys = [y0, y1] + for hx0, hx1, hy0, hy1 in holes: + xs.extend((hx0, hx1)) + ys.extend((hy0, hy1)) + xs = sorted(set(round(v, 8) for v in xs)) + ys = sorted(set(round(v, 8) for v in ys)) + + def in_hole(cx0, cx1, cy0, cy1): + mx = 0.5 * (cx0 + cx1) + my = 0.5 * (cy0 + cy1) + for hx0, hx1, hy0, hy1 in holes: + if hx0 < mx < hx1 and hy0 < my < hy1: + return True + return False + + grid = {} + for zi, z in ((0, z0), (1, z1)): + for ix, x in enumerate(xs): + for iy, y in enumerate(ys): + grid[(ix, iy, zi)] = bm.verts.new((x, y, z)) + + def quad(a, b, c, d): + face = bm.faces.new((a, b, c, d)) + face.material_index = mat_idx + + nx, ny = len(xs), len(ys) + for ix in range(nx - 1): + for iy in range(ny - 1): + if in_hole(xs[ix], xs[ix + 1], ys[iy], ys[iy + 1]): + continue + a = grid[(ix, iy, 1)] + b = grid[(ix + 1, iy, 1)] + c = grid[(ix + 1, iy + 1, 1)] + d = grid[(ix, iy + 1, 1)] + quad(a, b, c, d) + a = grid[(ix, iy, 0)] + b = grid[(ix, iy + 1, 0)] + c = grid[(ix + 1, iy + 1, 0)] + d = grid[(ix + 1, iy, 0)] + quad(a, b, c, d) + + for ix in range(nx - 1): + quad( + grid[(ix, 0, 0)], grid[(ix + 1, 0, 0)], + grid[(ix + 1, 0, 1)], grid[(ix, 0, 1)], + ) + quad( + grid[(ix, ny - 1, 0)], grid[(ix, ny - 1, 1)], + grid[(ix + 1, ny - 1, 1)], grid[(ix + 1, ny - 1, 0)], + ) + for iy in range(ny - 1): + quad( + grid[(0, iy, 0)], grid[(0, iy, 1)], + grid[(0, iy + 1, 1)], grid[(0, iy + 1, 0)], + ) + quad( + grid[(nx - 1, iy, 0)], grid[(nx - 1, iy + 1, 0)], + grid[(nx - 1, iy + 1, 1)], grid[(nx - 1, iy, 1)], + ) + + for hx0, hx1, hy0, hy1 in holes: + ix0 = xs.index(round(hx0, 8)) + ix1 = xs.index(round(hx1, 8)) + iy0 = ys.index(round(hy0, 8)) + iy1 = ys.index(round(hy1, 8)) + for ix in range(ix0, ix1): + quad( + grid[(ix, iy0, 0)], grid[(ix, iy0, 1)], + grid[(ix + 1, iy0, 1)], grid[(ix + 1, iy0, 0)], + ) + quad( + grid[(ix, iy1, 0)], grid[(ix + 1, iy1, 0)], + grid[(ix + 1, iy1, 1)], grid[(ix, iy1, 1)], + ) + for iy in range(iy0, iy1): + quad( + grid[(ix0, iy, 0)], grid[(ix0, iy + 1, 0)], + grid[(ix0, iy + 1, 1)], grid[(ix0, iy, 1)], + ) + quad( + grid[(ix1, iy, 0)], grid[(ix1, iy, 1)], + grid[(ix1, iy + 1, 1)], grid[(ix1, iy + 1, 0)], + ) -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"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) - v.co = rot @ p + 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_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( +def build_hoops(bm, spans, round_band): + faces_all = [] + for z_mid in HOOP_ZS: + z0 = z_mid - HOOP_H * 0.5 + z1 = z_mid + HOOP_H * 0.5 + c = HOOP_CHAMFER + sections = [] + samples = [] + for a0, a1 in spans: + samples.append(a0) + samples.append(0.5 * (a0 + a1)) + samples.append(a1) + for u in samples: + cu, su = math.cos(u), math.sin(u) + r0 = host_outer_r(u, z0, spans, round_band) + r1 = host_outer_r(u, z1, spans, round_band) + profile = ( + (r0 - HOOP_BITE, z0), + (r0 + HOOP_PROUD - c, z0), + (r0 + HOOP_PROUD, z0 + c), + (r1 + HOOP_PROUD, z1 - c), + (r1 + HOOP_PROUD - c, z1), + (r1 - HOOP_BITE, z1), + ) + sections.append([Vector((r * cu, r * su, z)) for r, z in profile]) + _verts, faces = loft_cyclic(bm, sections, METAL_IDX) + faces_all.extend(faces) + return faces_all + + +def build_staves(bm, spans, z0, bevel_offset, bevel_segments): + zs = [STUMP_H * i / (N_RINGS - 1) for i in range(N_RINGS)] + stave_verts = [] + for a0, a1 in spans: + outer = [] + inner = [] + for z in zs: + r = radius_at(z) + ov = ( + bm.verts.new((r * math.cos(a0), r * math.sin(a0), z0 + z)), + bm.verts.new((r * math.cos(a1), r * math.sin(a1), z0 + z)), + ) + ri = r - STAVE_THICK + iv = ( + bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z0 + z)), + bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z0 + z)), + ) + outer.append(ov) + inner.append(iv) + stave_verts.extend(ov) + stave_verts.extend(iv) + for k in range(N_RINGS - 1): + o0a, o0b = outer[k] + o1a, o1b = outer[k + 1] + i0a, i0b = inner[k] + i1a, i1b = inner[k + 1] + for vs in ( + (o0a, o1a, o1b, o0b), + (i0b, i1b, i1a, i0a), + (o0a, i0a, i1a, o1a), + (o0b, o1b, i1b, i0b), + ): + face = bm.faces.new(vs) + face.material_index = WOOD_IDX + top = bm.faces.new((outer[-1][0], outer[-1][1], inner[-1][1], inner[-1][0])) + top.material_index = WOOD_IDX + bot = bm.faces.new((outer[0][1], outer[0][0], inner[0][0], inner[0][1])) + bot.material_index = WOOD_IDX + if bevel_offset > 0.0: + long_edges = [] + seen = set() + for v in stave_verts: + for e in v.link_edges: + if e in seen: + continue + seen.add(e) + a, b = e.verts + if abs(a.co.z - b.co.z) > 0.02: + long_edges.append(e) + if long_edges: + ret = bmesh.ops.bevel( + bm, + geom=long_edges, + offset=min(bevel_offset, 0.004), + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + return stave_verts + + +def build_anvil_iron(bm, z_head, float_anvil, bevel_offset, bevel_segments): + z_foot0 = z_head - FOOT_BITE + (FLOAT_ANVIL if float_anvil else 0.0) + z_foot1 = z_foot0 + FOOT_H + z_body0 = z_foot1 + WAIST_H + z_face = z_body0 + FACE_T + hy = FACE_W * 0.5 + x_heel = -FACE_LEN * 0.5 - HEEL_LEN + x_face0 = -FACE_LEN * 0.5 + x_hardy0 = x_face0 + 0.042 + x_hardy1 = x_hardy0 + 0.055 + x_prit0 = 0.055 + x_prit1 = x_prit0 + 0.048 + x_face1 = FACE_LEN * 0.5 + x_table1 = x_face1 + TABLE_LEN + x_horn1 = x_table1 + HORN_LEN - 0.020 + + pr_half = PRITCHEL_R + pr_cx = 0.5 * (x_prit0 + x_prit1) + add_slotted_slab( bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), + x_heel, x_face1, + -hy, hy, + z_body0, z_face, + [ + ( + x_hardy0 + 0.012, x_hardy1 - 0.012, + -HARDY_HALF, HARDY_HALF, + ), + ( + pr_cx - pr_half, pr_cx + pr_half, + -pr_half, pr_half, + ), + ], + METAL_IDX, ) - - -def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( + add_box_solid( bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius, - radius2=radius, - depth=depth, + x_face1 - 0.006, x_table1, + -hy * 0.88, hy * 0.88, + z_body0 + 0.004, z_face - TABLE_DROP, + METAL_IDX, ) - 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( + + z_mid = 0.5 * ((z_body0 + 0.004) + (z_face - TABLE_DROP)) + horn_rings = [ + oval_ring(x_table1 - 0.018, hy * 0.80, 0.018, z_mid), + oval_ring(x_table1 + 0.040, hy * 0.58, 0.015, z_mid - 0.004), + oval_ring(x_table1 + 0.095, hy * 0.34, 0.011, z_mid - 0.010), + oval_ring(x_table1 + 0.150, hy * 0.16, 0.008, z_mid - 0.016), + oval_ring(x_horn1, 0.011, 0.008, z_mid - 0.022), + ] + horn_verts, _faces = loft_open(bm, horn_rings, METAL_IDX, cap0=True, cap1=True) + + add_box_solid( bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + -FOOT_XY[0] * 0.5, FOOT_XY[0] * 0.5, + -FOOT_XY[1] * 0.5, FOOT_XY[1] * 0.5, + z_foot0, z_foot1 + 0.004, + METAL_IDX, ) - 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_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) + pinch_hy = 0.028 + pinch_hx = 0.055 + waist_mid = z_foot1 + WAIST_H * 0.48 + waist_z = (z_foot1, waist_mid, z_body0 + 0.002) + waist_hy = (FOOT_XY[1] * 0.46, pinch_hy, hy * 0.42) + waist_hx = (FOOT_XY[0] * 0.42, pinch_hx, FACE_LEN * 0.22) + real_waist = [] + for z, hx, hy_w in zip(waist_z, waist_hx, waist_hy): 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 + for i in range(N_SECTION): + t = i * (2.0 * math.pi / N_SECTION) + ring.append(Vector((hx * math.cos(t), hy_w * math.sin(t), z))) + real_waist.append(ring) + loft_open(bm, real_waist, METAL_IDX, cap0=True, cap1=True) + return z_foot0, z_face, list(horn_verts) + + +def triangulate_ngons(bm): + faces = [f for f in bm.faces if len(f.verts) > 4] + if faces: + bmesh.ops.triangulate(bm, faces=faces) def pack_uvs(bm, margin=0.08): @@ -246,174 +591,32 @@ def pack_uvs(bm, margin=0.08): ) -def build_anvil_mesh(name, bevel_offset, bevel_segments): +def build_anvil_mesh( + name, + bevel_offset, + bevel_segments, + short_staves=False, + float_anvil=False, + round_band=False, +): bm = bmesh.new() + gap_ang = GAP_M / R_BOT + spans = stave_spans(N_STAVES, gap_ang, STAVE_JITTER, STAVE_SEED) + z0 = SHORT_STAVES_LIFT if short_staves else 0.0 try: - wood = [] - metal = [] - - wood.extend( - add_cone( - bm, - (0.0, 0.0, STUMP_H / 2.0), - STUMP_R + 0.010, - STUMP_R - 0.018, - STUMP_H, - 24, - WOOD_IDX, - ) - ) - wood.extend( - add_cyl( - bm, - (0.0, 0.0, STUMP_H + 0.010), - STUMP_R - 0.022, - 0.020, - 24, - WOOD_IDX, - ) - ) - - if bevel_offset > 0.0: - edges = list({e for v in wood for e in v.link_edges}) - ret = bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - for f in ret.get("faces") or []: - f.material_index = WOOD_IDX - - for z in (0.048, STUMP_H - 0.042): - metal.extend( - add_rim(bm, (0.0, 0.0, z), STUMP_R + 0.004, 0.011, METAL_IDX) - ) - - z0 = STUMP_H - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.028), - (0.44, 0.20, 0.056), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.078), - (0.30, 0.130, 0.046), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.155), - (0.150, 0.058, 0.112), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (0.0, 0.0, z0 + 0.236), - (0.36, 0.108, 0.050), - METAL_IDX, - ) - ) - face_z = z0 + 0.236 + 0.025 + FACE_T / 2.0 - metal.extend( - add_box( - bm, - (0.0, 0.0, face_z), - (FACE_L, FACE_W, FACE_T), - METAL_IDX, - ) - ) - - horn_z = face_z - 0.006 - metal.extend( - add_cone( - bm, - (FACE_L / 2.0 + HORN_L / 2.0 - 0.050, 0.0, horn_z), - FACE_W * 0.46, - 0.012, - HORN_L, - 20, - METAL_IDX, - euler=(0.0, math.pi / 2.0, 0.0), - ) + build_staves(bm, spans, z0, bevel_offset, bevel_segments) + head_z0 = z0 + STUMP_H - CHIME - HEAD_T + head_z1 = z0 + STUMP_H - CHIME + add_polygon_disk( + bm, head_z0, head_z1, + croze_xy(spans, 0.5 * (head_z0 + head_z1), 0.004, 0.0), + WOOD_IDX, ) - metal.extend( - add_box( - bm, - (FACE_L / 2.0 - 0.010, 0.0, horn_z - 0.014), - (0.12, FACE_W * 0.88, 0.040), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (-FACE_L / 2.0 - HEEL_L / 2.0 + 0.018, 0.0, face_z - 0.004), - (HEEL_L, FACE_W * 0.92, FACE_T * 0.82), - METAL_IDX, - ) - ) - metal.extend( - add_box( - bm, - (-FACE_L * 0.22, 0.0, face_z + FACE_T / 2.0 - 0.005), - (0.028, 0.028, 0.012), - METAL_IDX, - ) - ) - metal.extend( - add_cyl( - bm, - (FACE_L * 0.22, 0.0, face_z + FACE_T / 2.0 - 0.004), - 0.008, - 0.012, - 12, - METAL_IDX, - ) - ) - - xs = [v.co.x for v in bm.verts] - ys = [v.co.y for v in bm.verts] - 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 - - if bevel_offset > 0.0: - metal_edges = list( - {e for v in metal for e in v.link_edges if v.is_valid} - ) - if metal_edges: - ret = bmesh.ops.bevel( - bm, - geom=metal_edges, - offset=max(bevel_offset, 0.012), - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - for f in ret.get("faces") or []: - f.material_index = METAL_IDX - + build_anvil_iron(bm, head_z1, float_anvil, bevel_offset, bevel_segments) + build_hoops(bm, spans, round_band) + bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5) + bmesh.ops.dissolve_degenerate(bm, dist=1e-6) + triangulate_ngons(bm) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: @@ -421,25 +624,45 @@ def build_anvil_mesh(name, bevel_offset, bevel_segments): 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(38.0): + if edge.calc_face_angle() > math.radians(35.0): edge.smooth = False me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() finally: bm.free() - out = bpy.data.objects.new(name, me) - bpy.context.collection.objects.link(out) - return out + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj -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 = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + 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"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.18) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat @@ -461,6 +684,17 @@ def world_bbox(obj): return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) +def face_area(me, poly): + verts = [me.vertices[i].co for i in poly.vertices] + if len(verts) < 3: + return 0.0 + acc = Vector((0.0, 0.0, 0.0)) + origin = verts[0] + for a, b in zip(verts[1:], verts[2:]): + acc += (a - origin).cross(b - origin) + return 0.5 * acc.length + + def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: @@ -486,11 +720,210 @@ def uv_stats(mesh): return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +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) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + 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 p in me.polygons: + if all(i in member for i in p.vertices): + return p.material_index + return None + + +def support_audit(me): + groups = shells(me) + staves = [] + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dz = a[5] - a[2] + if dz > 0.20: + staves.append(a) + stave_z = min((a[2] for a in staves), default=99.0) + return {"staves": len(staves), "stave_z": stave_z} + + +def hoop_seat(me, spans): + groups = shells(me) + bites = [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dz > 0.06 or max(dx, dy) < 0.28: + continue + rs = [math.hypot(me.vertices[i].co.x, me.vertices[i].co.y) for i in g] + if min(rs) < R_TOP * 0.6: + continue + for i, r in zip(g, rs): + p = me.vertices[i].co + u = math.atan2(p.y, p.x) + host = host_outer_r(u, p.z, spans, False) + if r > host + 0.0005: + continue + bites.append(host - r) + if not bites: + return 0.05, 0.05 + return min(bites), max(bites) + + +def anvil_seat(me): + groups = shells(me) + woods, metals = [], [] + for g in groups: + a = shell_aabb(me, g) + mat = mat_of(me, g) + dz = a[5] - a[2] + if mat == WOOD_IDX and dz < 0.08 and a[2] > STUMP_H * 0.6: + woods.append(g) + elif mat == METAL_IDX: + radial = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if a[5] < STUMP_H * 0.95 and radial > 0.15: + continue + metals.append((a[2], g)) + if not woods or not metals: + return 99.0 + metals.sort() + foots = [metals[0][1]] + bm_s = bmesh.new() + try: + bm_s.from_mesh(me) + keep = set() + for g in woods: + keep.update(g) + drop = [f for f in bm_s.faces if not all(v.index in keep for v in f.verts)] + if drop: + bmesh.ops.delete(bm_s, geom=drop, context="FACES") + if not bm_s.faces: + return 99.0 + tree = BVHTree.FromBMesh(bm_s) + worst = 0.0 + for g in foots: + zmin = min(me.vertices[i].co.z for i in g) + for i in g: + p = me.vertices[i].co + if p.z > zmin + 0.006: + continue + loc, _n, _i, dist = tree.find_nearest(p) + if loc is None: + continue + worst = max(worst, dist) + return worst + finally: + bm_s.free() + + +def body_plan(me): + groups = shells(me) + xs, z0s, z1s = [], [], [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + radial = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if a[5] < STUMP_H * 0.95 and radial > 0.15: + continue + xs.extend((a[0], a[3])) + z0s.append(a[2]) + z1s.append(a[5]) + if not xs: + return 0.0, 0.0 + return max(xs) - min(xs), max(z1s) - min(z0s) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, STUMP_H * 0.5)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) lod.matrix_world = obj.matrix_world.copy() - bpy.context.scene.collection.objects.link(lod) + bpy.context.collection.objects.link(lod) if not skip_decimate and 0.0 < ratio < 1.0: mod = lod.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" @@ -510,6 +943,14 @@ def convex_hull_collider(obj, name): bmesh.ops.delete(bm, geom=interior, context="VERTS") if unused: bmesh.ops.delete(bm, geom=unused, context="VERTS") + bmesh.ops.dissolve_limit( + bm, + angle_limit=math.radians(10.0), + verts=list(bm.verts), + edges=list(bm.edges), + delimit={"NORMAL"}, + ) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(mesh) mesh.update() finally: @@ -540,7 +981,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 @@ -558,7 +1000,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] @@ -572,18 +1015,40 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_staves=False, + float_anvil=False, + round_band=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_anvil_mesh("AnvilLow", bevel_offset=0.010, bevel_segments=2) - high = build_anvil_mesh("AnvilHigh", bevel_offset=0.010, bevel_segments=4) - wood = principled("AnvilStump", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62) - metal = principled("AnvilSteel", (0.16, 0.15, 0.14, 1.0), 0.90, 0.34) + flags = dict(short_staves=short_staves, float_anvil=float_anvil, round_band=round_band) + low = build_anvil_mesh("AnvilLow", 0.004, 2, **flags) + high = build_anvil_mesh("AnvilHigh", 0.004, 4, **flags) + wood = principled( + "AnvilStump", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62, + noise_scale=6.5, wear=(0.22, 0.12, 0.05, 1.0), + ) + metal = principled( + "AnvilSteel", (0.18, 0.18, 0.19, 1.0), 0.92, 0.32, + noise_scale=5.5, wear=(0.10, 0.10, 0.11, 1.0), + ) assign_slots(low, wood, metal) assign_slots(high, wood, metal) + 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() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("anvil mesh did not build", 3), None, None, None, None, None + spans = stave_spans(N_STAVES, GAP_M / R_BOT, STAVE_JITTER, STAVE_SEED) base_tris = triangle_count(low.data) mats = [s for s in low.data.materials if s is not None] nmat = len(mats) @@ -597,6 +1062,22 @@ def check(skip_decimate): size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + bite_min, bite_max = hoop_seat(low.data, spans) + seat = anvil_seat(low.data) + blen, bht = body_plan(low.data) + 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 staves={sup['staves']} stave_z={sup['stave_z']:.5f} " + f"hoop_bite={bite_min:.5f}..{bite_max:.5f} seat={seat:.5f} " + f"body={blen:.4f}x{bht:.4f}" + ) img, tex = setup_bake_image(low, wood) if img is None: @@ -611,7 +1092,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_anvil_mesh("AnvilColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_anvil_mesh("AnvilColSrc", 0.0, 1) collider = convex_hull_collider(collider_src, "AnvilCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -706,21 +1187,40 @@ 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 + if bb[2] > ZMIN_EPS or sup["staves"] != STAVE_COUNT or sup["stave_z"] > STAVE_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} staves={sup['staves']} " + f"stave_z={sup['stave_z']:.5f}", + 16, + ), None, None, None, None, None + if seat > SEAT_GAP_MAX: + return fail(f"anvil seat gap {seat:.5f} > {SEAT_GAP_MAX}", 17), None, None, None, None, None + if bite_min < HOOP_BITE_MIN or bite_max > HOOP_BITE_MAX: + return fail( + f"hoop bite {bite_min:.5f}..{bite_max:.5f} " + f"not in [{HOOP_BITE_MIN}, {HOOP_BITE_MAX}]", + 18, + ), None, None, None, None, None + if abs(blen - BODY_LEN) > BODY_TOL or abs(bht - BODY_H) > BODY_TOL: + return fail( + f"anvil body {blen:.4f}x{bht:.4f} off {BODY_LEN}x{BODY_H}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider -def wire_normal(mat, tex): - nt = mat.node_tree - bsdf = nt.nodes["Principled BSDF"] - nrm = nt.nodes.new("ShaderNodeNormalMap") - nrm.inputs["Strength"].default_value = 1.0 - nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) - nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) - - -def render_still(low, wood, tex, path, engine): +def render_still(low, _wood, _tex, path, engine): scene = bpy.context.scene - wire_normal(wood, tex) for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True @@ -770,13 +1270,15 @@ def light(name, loc, energy, size, col, rot): light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.32, -1.90, 1.04) + cam.location = (span * 1.61, -span * 2.35, span * 1.32) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.44) + aim.location = (0.02 * span, 0.0, 0.5 * (bb[2] + bb[5]) + 0.04 * span) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -819,14 +1321,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("--short-staves", action="store_true") + p.add_argument("--float-anvil", action="store_true") + p.add_argument("--round-band", 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_staves=args.short_staves, + float_anvil=args.float_anvil, + round_band=args.round_band, + ) if code: return code if args.output: diff --git a/showcase/anvil/preview.webp b/showcase/anvil/preview.webp index da6fc1a94353a9e19a8a5480364b633fc9160675..39b42162496f1e5890578c2fe8b4257c0aedcaf8 100644 GIT binary patch literal 26766 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 B>hS;o literal 8232 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 a=HqHekUP`>00004u9{=o$HC>h000147PpN6 From 67fb263ef40ac63846d3bf7f4a5fc8685def1bb1 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:28:50 -0400 Subject: [PATCH 2/2] fix(showcase): rebuild campfire with seated courses and a kissing teepee The old ring floated a mortar gap between identical boxes and ended the tripod at one point. Shared-Z courses and an overshooting apex ring are durable; per-log coordinate nudges are not. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- docs/gallery/assets/campfire-hero.webp | Bin 26922 -> 36024 bytes docs/gallery/campfire/index.html | 883 ++++++++++++++++++------- docs/gallery/index.html | 4 +- showcase/campfire/README.md | 89 ++- showcase/campfire/campfire.py | 853 +++++++++++++++++------- showcase/campfire/preview.webp | Bin 17670 -> 36024 bytes showcase/gallery.json | 4 +- 7 files changed, 1334 insertions(+), 499 deletions(-) diff --git a/docs/gallery/assets/campfire-hero.webp b/docs/gallery/assets/campfire-hero.webp index 3cf3e733981bff3fbd612f426ac8016168f823c0..9f1d0ac408627984cfa3dca23f0fa6126229373b 100644 GIT binary patch literal 36024 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 Izy@3Z0FQU(3jhEB literal 26922 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 Bsd@ka diff --git a/docs/gallery/campfire/index.html b/docs/gallery/campfire/index.html index 7790d34..b212fcb 100644 --- a/docs/gallery/campfire/index.html +++ b/docs/gallery/campfire/index.html @@ -253,28 +253,42 @@

campfire

campfire render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 3616 tris, three materials with 100 wood and 48 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.789×0.789×0.410 m, LOD ratios in band, convex collider 254 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 2200 tris, three materials with 216 wood, 48 ash and 1304 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.807×0.808×0.404 m, LOD ratios in band, convex collider 198 tris, hygiene 0, 14 grounded bottom stones, teepee kiss 0.00001 m, course seat 0, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-stones 16; --float-logs 17; --gap-courses 18.
blender --background --python showcase/campfire/campfire.py --
-

A showcase piece, not an example. Procedural campfire (two-course running-bond stone ring, pit-floor cobbles, ash, a log tripod, and charcoal) 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 campfire (two-course running-bond stone ring of 14 barrelled wedges, pit-floor cobbles, ash, a kissing log tripod that overshoots a shared apex ring, floor logs, and charcoal) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

Courses share a Z plane; --gap-courses is the falsifier. Teepee axes aim at a ring of radius (STICK_R * 0.55) / sin(π/N) and overshoot so the caps cross; --float-logs inflates that ring.

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

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 | 3500–3750 | 3616 / 3616 / 3616 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | | Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 100 wood, 48 ash | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.789, 0.789, 0.410) m ± 0.01 | (0.7891, 0.7891, 0.4094), zmin 0 | | Collider tris | ≤ 320 | 254 | | Export | written, size > 0 | 273604 / 273604 / 273596 bytes |

-

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.

-

--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–3600 | 2200 / 2200 / 2200 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 | | Materials | exactly 3 distinct; ≥80 wood, ≥24 ash, ≥200 stone | 3 slots; 216 / 48 / 1304 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.807, 0.808, 0.404) m ± 0.015 | (0.8069, 0.8079, 0.4037), zmin 0 | | Collider tris | ≤ 360 | 198 | | Export | written, size > 0 | 206244 / 206244 / 206236 bytes |

+

Base triangles dropped from 3616 to 2200 in the quality pass: the old piece stacked identical boxes with a mortar gap and a teepee that met at one point. The rebuild is two seated courses plus crossing sticks; hidden overlapping volume went away.

+

DECIMATE COLLAPSE triangle counts are not guaranteed 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. Stone-width jitter uses fixed seed 17. glTF byte size differs by 8 B on 5.2.1.

+

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 supports: 14 bottom stones | each zmin ≤ 0.001 | 14, stone_z 0.00000 | | Ring plan | 0.807 m dia × 0.156 m high ± 0.04 | 0.8074 × 0.1560 |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Teepee kiss (BVH nearest at caps) | ≤ 0.008 m | 0.00001 | | Course seat (upper zmin − lower zmax) | abs ≤ 0.004 m | 0.00000 |

+

Falsifiers

+

Each violates one named budget. All six 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-stones | named bottom-course supports at Z=0 | 16 | | --float-logs | teepee kiss | 17 | | --gap-courses | course seat | 18 |

Run

blender --background --python campfire.py --
 blender --background --python campfire.py -- --skip-decimate
+blender --background --python campfire.py -- --stray-vert
+blender --background --python campfire.py -- --lift-z
+blender --background --python campfire.py -- --short-stones
+blender --background --python campfire.py -- --float-logs
+blender --background --python campfire.py -- --gap-courses
 blender --background --python campfire.py -- --output campfire.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/ash 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. 1519 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 a face-count floor missed | | 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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named bottom stone floats | | 17 | Joint fit: teepee kiss (--float-logs) | | 18 | Seat: course gap (--gap-courses) | | 19 | Ring diameter or height off the stated real-world size |

Source

@@ -284,15 +298,24 @@

Source

"""Game-ready campfire — a showcase piece, not an example.
 
-Asserts budget conformance of a procedural campfire after composing
-shipped pipeline pieces: bmesh construction, UVs, three materials,
-high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+Asserts budget conformance of a procedural campfire (two-course
+wedge-stone ring stacked on a shared plane, pit-floor cobbles and ash,
+a kissing log tripod, and resting firewood) after composing shipped
+pipeline pieces: bmesh construction, UVs, three materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+The old piece was two floating courses of identical boxes with a
+daylight mortar gap, a 12-gon teepee whose tips occupied the same
+point, and an empty pit.
 
 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.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--short-stones`` named
+bottom-course supports, ``--float-logs`` teepee kiss, ``--gap-courses``
+the stacked-course seat.
 
-No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+Fixed seed 17 for stone-width jitter. DECIMATE COLLAPSE triangle counts
 are not byte-identical across Blender versions — the LOD gate is a
 ratio band, not an exact count.
 
@@ -303,17 +326,16 @@ 

Source

import argparse import math import os +import random import sys import tempfile import traceback import bmesh import bpy -from mathutils import Euler, Vector +from mathutils import 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,28 +343,47 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -N_AROUND = 12 +N_AROUND = 14 N_ROWS = 2 -R_INNER = 0.28 -STONE_D = 0.11 -R_OUTER = R_INNER + STONE_D -R_MID = (R_INNER + R_OUTER) / 2.0 -WALL_H = 0.155 -STONE_H = WALL_H / N_ROWS -N_FLOOR = 6 -N_ASH = 7 -N_LOGS = 2 -LOG_LEN = 0.34 -LOG_R = 0.032 +R_INNER = 0.26 +R_OUTER = 0.39 +R_MID = 0.5 * (R_INNER + R_OUTER) +STONE_H = 0.078 +STONE_SEED = 17 +STONE_JITTER = 0.10 +GAP_ANG = 0.004 +N_COBBLES = 12 +COBBLE_H = 0.016 +ASH_H = 0.010 +ASH_R = 0.20 N_STICKS = 3 -STICK_R = 0.030 -N_COALS = 5 -BBOX_TOL = 0.01 -# Fitted to the generated AABB after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (0.789, 0.789, 0.410) - -BASE_TRIS_MIN = 3500 -BASE_TRIS_MAX = 3750 +STICK_R = 0.028 +STICK_SEGS = 12 +LOG_R = 0.030 +LOG_LEN = 0.28 +Z_APEX = 0.365 +R_BASE = 0.20 +COURSE_GAP = 0.022 +FLOAT_LOG = 0.055 +SHORT_STONES_LIFT = 0.040 +LIFT_Z = 0.05 +KISS_MAX = 0.008 +COURSE_SEAT_MAX = 0.004 +STAVE_ZMIN_MAX = 0.001 + +AREA_EPS = 1e-10 +DOUBLES_EPS = 1e-5 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +BODY_TOL = 0.04 +RING_DIA = 0.807 +RING_H = 0.156 +BBOX_TOL = 0.015 +OUTER_SIZE = (0.807, 0.808, 0.404) + +BASE_TRIS_MIN = 1400 +BASE_TRIS_MAX = 3600 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -350,11 +391,15 @@

Source

LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 3 +WOOD_FACES_MIN = 80 +ASH_FACES_MIN = 24 +STONE_FACES_MIN = 200 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 320 +COLLIDER_TRIS_MAX = 360 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 +BOTTOM_COUNT = N_AROUND STONE_IDX = 0 WOOD_IDX = 1 @@ -376,7 +421,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() @@ -387,66 +431,113 @@

Source

eval_obj.to_mesh_clear() -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"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) - v.co = rot @ p + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx +def stone_spans(n, gap_ang, jitter, seed): + rng = random.Random(seed) + weights = [1.0 + rng.uniform(-jitter, jitter) for _ in range(n)] + total = sum(weights) + usable = 2.0 * math.pi - n * gap_ang + spans = [] + a = 0.0 + for w in weights: + width = usable * (w / total) + spans.append((a, a + width)) + a += width + gap_ang + return spans + + +def add_wedge(bm, a0, a1, r_in, r_out, z0, z1, mat_idx, lump=0.0): + mid_z = 0.5 * (z0 + z1) + mid_ang = 0.5 * (a0 + a1) + layers = ( + (z0, 0.985, 0.0), + (mid_z, 1.025, lump), + (z1, 0.97, 0.0), + ) + rings = [] + for z, r_scale, lp in layers: + ring = [] + for ang, r in ( + (a0, r_in), + (a1, r_in), + (a1, r_out * r_scale), + (a0, r_out * r_scale), + ): + x = r * math.cos(ang) + y = r * math.sin(ang) + if lp > 0.0 and r > 0.5 * (r_in + r_out): + h = math.sin(ang * 5.0 + z * 17.0 + mid_ang * 3.0) + scale = 1.0 + lp * max(-0.7, min(0.7, h)) + x *= scale + y *= scale + ring.append(bm.verts.new((x, y, z))) + rings.append(ring) + verts = [v for ring in rings for v in ring] + for k in range(len(rings) - 1): + a, b = rings[k], rings[k + 1] + for i in range(4): + j = (i + 1) % 4 + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + bot = rings[0] + top = rings[-1] + face = bm.faces.new((bot[0], bot[3], bot[2], bot[1])) + face.material_index = mat_idx + face = bm.faces.new((top[0], top[1], top[2], top[3])) + face.material_index = mat_idx return verts -def add_rock(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0), lump=0.0): - # Bevelled boxes, same language as stone-well masonry — not icospheres. - # Subdiv-1 icos read as d20s at thumbnail regardless of smooth shading. - verts = add_box(bm, loc, scale, mat_idx, euler) - if lump <= 0.0: - return verts - origin = Vector(loc) - rot = Euler(euler).to_matrix() - inv = rot.inverted() +def add_cyl_between(bm, a, b, r0, r1, segs, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=True, + segments=segs, + radius1=r0, + radius2=r1, + depth=length, + ) + verts = list(geo["verts"]) + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + rot = quat.to_matrix() + mid = (a + b) * 0.5 for v in verts: - local = inv @ (v.co - origin) - h = math.sin(local.x * 15.7 + local.y * 11.3 + local.z * 9.1) - h += 0.35 * math.cos(local.y * 21.0 + local.x * 8.0) - h = max(-0.85, min(0.85, h)) - local = Vector(( - local.x * (1.0 + lump * h), - local.y * (1.0 + lump * 0.65 * math.sin(local.z * 13.0 + 1.2)), - local.z * (1.0 + lump * 0.40 * h), - )) - v.co = rot @ local + origin + v.co = rot @ v.co + mid + 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)): +def add_ash_disk(bm, radius, height, segs, mat_idx): geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + cap_tris=True, + segments=segs, + radius1=radius, + radius2=radius * 0.92, + depth=height, ) - verts = geo["verts"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) + verts = list(geo["verts"]) for v in verts: - v.co = rot @ v.co + origin + v.co.z += height * 0.5 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_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 triangulate_ngons(bm): + faces = [f for f in bm.faces if len(f.verts) > 4] + if faces: + bmesh.ops.triangulate(bm, faces=faces) def pack_uvs(bm, margin=0.08): @@ -492,141 +583,145 @@

Source

) -def build_campfire_mesh(name, bevel_offset, bevel_segments): +def build_campfire_mesh( + name, + bevel_offset, + bevel_segments, + short_stones=False, + float_logs=False, + gap_courses=False, +): bm = bmesh.new() + spans = stone_spans(N_AROUND, GAP_ANG, STONE_JITTER, STONE_SEED) + z_ground = SHORT_STONES_LIFT if short_stones else 0.0 + extra = COURSE_GAP if gap_courses else 0.0 + stone_verts = [] try: - # Two-course running-bond ring — same masonry as stone-well, not a - # circle of cubes or icos. Bevel stone edges only; logs go on after. - stone_verts = [] - stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88 - actual_h = STONE_H * 0.90 for row in range(N_ROWS): - z = actual_h / 2.0 + row * STONE_H + z0 = z_ground + row * STONE_H + (extra if row else 0.0) + z1 = z0 + STONE_H rot_off = (row % 2) * (math.pi / N_AROUND) - for i in range(N_AROUND): - ang = 2.0 * math.pi * i / N_AROUND + rot_off - loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z) + for a0, a1 in spans: stone_verts.extend( - add_box( + add_wedge( bm, - loc, - (STONE_D, stone_w, actual_h), + a0 + rot_off, + a1 + rot_off, + R_INNER, + R_OUTER, + z0, + z1, STONE_IDX, - euler=(0.0, 0.0, ang), + lump=0.022, ) ) - for i in range(N_FLOOR): - ang = 2.0 * math.pi * i / N_FLOOR + 0.22 - r = 0.11 + 0.02 * (i % 2) - sz = 0.032 + 0.008 * (i % 3) - loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50) - stone_verts.extend( - add_box( + if bevel_offset > 0.0: + vertical = [] + seen = set() + for v in stone_verts: + for e in v.link_edges: + if e in seen: + continue + seen.add(e) + a, b = e.verts + if abs(a.co.z - b.co.z) > 0.02: + vertical.append(e) + if vertical: + ret = bmesh.ops.bevel( bm, - loc, - (0.078, 0.058, sz), - STONE_IDX, - euler=(0.0, 0.0, ang), + geom=vertical, + offset=min(bevel_offset, 0.008), + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, ) - ) - - if bevel_offset > 0.0: - edges = list({e for v in stone_verts for e in v.link_edges}) - bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - - add_rock( - bm, - (0.0, 0.0, 0.014), - (0.10, 0.09, 0.024), - ASH_IDX, - lump=0.14, - ) - for i in range(N_ASH): - ang = 2.0 * math.pi * i / N_ASH + 0.18 - r = 0.022 + 0.040 * ((i % 4) / 3.0) - sz = 0.016 + 0.010 * abs(math.sin(i * 1.7)) - loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50) - add_rock( - bm, - loc, - (0.032 + 0.006 * (i % 3), 0.026, sz), - ASH_IDX, - euler=(0.0, 0.0, ang), - lump=0.16, - ) - - for i in range(N_LOGS): - yaw = i * (math.pi / 2.0) + math.radians(18.0) - loc = ( - 0.04 * math.cos(yaw + math.pi / 2.0), - 0.04 * math.sin(yaw + math.pi / 2.0), - LOG_R + 0.018 + 0.012 * i, - ) - add_cone( - bm, - loc, - LOG_R * (1.0 - 0.04 * i), - LOG_R * 0.88, - LOG_LEN, - 12, - WOOD_IDX, - euler=(math.pi / 2.0, 0.0, yaw), - ) - - # Teepee aimed at a shared apex so the sticks read as one fire, not - # four independent posts. Cone local +Z tracks base -> apex. - z_apex = WALL_H + 0.24 - z_base = 0.040 - r_base = R_INNER - 0.05 + for f in ret.get("faces") or []: + f.material_index = STONE_IDX + + add_ash_disk(bm, ASH_R, ASH_H, 16, ASH_IDX) + rng = random.Random(STONE_SEED) + for i in range(N_COBBLES): + ang = 2.0 * math.pi * i / N_COBBLES + 0.18 + r = 0.07 + 0.05 * ((i % 3) / 2.0) + sz = 0.028 + 0.010 * (i % 2) + loc = Vector((r * math.cos(ang), r * math.sin(ang), COBBLE_H * 0.5)) + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + for v in verts: + p = Vector(( + v.co.x * (sz * 1.4), + v.co.y * sz, + v.co.z * COBBLE_H, + )) + h = rng.uniform(-0.12, 0.12) + p = Vector((p.x * (1.0 + h), p.y * (1.0 - 0.5 * h), p.z)) + rot_z = ang + rng.uniform(-0.2, 0.2) + c, s = math.cos(rot_z), math.sin(rot_z) + v.co = Vector(( + p.x * c - p.y * s, + p.x * s + p.y * c, + p.z, + )) + loc + for f in {f for v in verts for f in v.link_faces}: + f.material_index = STONE_IDX + + apex_ring = (STICK_R * 0.55) / math.sin(math.pi / N_STICKS) + if float_logs: + apex_ring *= 2.8 + z_base = COBBLE_H + STICK_R * 0.35 + overshoot = STICK_R * 1.15 for i in range(N_STICKS): - yaw = i * (2.0 * math.pi / N_STICKS) + math.pi / 2.0 + yaw = i * (2.0 * math.pi / N_STICKS) + math.pi / 6.0 base = Vector(( - r_base * math.cos(yaw), - r_base * math.sin(yaw), + R_BASE * math.cos(yaw), + R_BASE * math.sin(yaw), z_base, )) - apex = Vector((0.0, 0.0, z_apex)) - delta = apex - base - mid = (base + apex) * 0.5 - rot = delta.to_track_quat("Z", "Y").to_euler() - add_cone( - bm, - (mid.x, mid.y, mid.z), - STICK_R, - STICK_R * 0.88, - delta.length, - 12, - WOOD_IDX, - euler=(rot.x, rot.y, rot.z), - ) - - for i in range(N_COALS): - ang = 2.0 * math.pi * i / N_COALS + 0.11 - r = 0.030 + 0.022 * (i % 3) - sz = 0.014 + 0.008 * abs(math.sin(i * 2.2)) - loc = (r * math.cos(ang), r * math.sin(ang), 0.022 + sz / 2.0) - add_rock( - bm, - loc, - (0.026, 0.020, sz), - WOOD_IDX, - euler=(0.0, 0.0, ang), - lump=0.18, + apex = Vector(( + apex_ring * math.cos(yaw), + apex_ring * math.sin(yaw), + Z_APEX - i * (STICK_R * 0.55), + )) + direction = (apex - base).normalized() + end = apex + direction * overshoot + add_cyl_between( + bm, base, end, STICK_R, STICK_R * 0.78, STICK_SEGS, WOOD_IDX, ) - for v in bm.verts: - if v.co.z < 0.0: - v.co.z = 0.0 + for i in range(2): + yaw = i * (math.pi / 2.0) + math.radians(22.0) + z = COBBLE_H + LOG_R + 0.010 * i + a = Vector(( + 0.5 * LOG_LEN * math.cos(yaw), + 0.5 * LOG_LEN * math.sin(yaw), + z, + )) + b = Vector((-a.x, -a.y, z)) + add_cyl_between(bm, a, b, LOG_R * (1.0 - 0.06 * i), LOG_R * 0.86, 12, WOOD_IDX) + for i in range(6): + ang = 2.0 * math.pi * i / 6.0 + 0.4 + r = 0.04 + 0.02 * (i % 2) + sz = 0.018 + 0.006 * (i % 3) + loc = ( + r * math.cos(ang), + r * math.sin(ang), + ASH_H + sz * 0.45, + ) + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + for v in verts: + p = Vector((v.co.x * sz * 1.3, v.co.y * sz, v.co.z * sz * 0.7)) + c, s = math.cos(ang), math.sin(ang) + v.co = Vector((p.x * c - p.y * s, p.x * s + p.y * c, p.z)) + Vector(loc) + for f in {f for v in verts for f in v.link_faces}: + f.material_index = WOOD_IDX + + triangulate_ngons(bm) + bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5) + bmesh.ops.dissolve_degenerate(bm, dist=1e-6) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: @@ -652,19 +747,37 @@

Source

return obj -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 = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + 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"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.16) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat def assign_slots(obj, stone, wood, ash): - # Do not materials.clear() — that resets polygon material_index to 0 - # on this Blender, which would drop wood/ash faces onto stone. mats = obj.data.materials wanted = (stone, wood, ash) for i, mat in enumerate(wanted): @@ -682,6 +795,17 @@

Source

return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) +def face_area(me, poly): + verts = [me.vertices[i].co for i in poly.vertices] + if len(verts) < 3: + return 0.0 + acc = Vector((0.0, 0.0, 0.0)) + origin = verts[0] + for a, b in zip(verts[1:], verts[2:]): + acc += (a - origin).cross(b - origin) + return 0.5 * acc.length + + def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: @@ -707,11 +831,209 @@

Source

return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +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) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + 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 p in me.polygons: + if all(i in member for i in p.vertices): + return p.material_index + return None + + +def support_audit(me): + groups = shells(me) + bottoms = [] + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + a = shell_aabb(me, g) + dz = a[5] - a[2] + r = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if dz > 0.05 and r > 0.04 and a[2] < 0.02: + bottoms.append(a) + zmin = min((a[2] for a in bottoms), default=99.0) + return {"stones": len(bottoms), "stone_z": zmin} + + +def course_seat(me): + groups = shells(me) + lower, upper = [], [] + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + a = shell_aabb(me, g) + dz = a[5] - a[2] + r = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if dz < 0.05 or r < 0.04: + continue + mid = 0.5 * (a[2] + a[5]) + if mid < STONE_H: + lower.append(a) + else: + upper.append(a) + if not lower or not upper: + return 99.0 + return min(a[2] for a in upper) - max(a[5] for a in lower) + + +def teepee_kiss(me): + groups = shells(me) + sticks = [] + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + if a[5] < 0.25: + continue + sticks.append(g) + if len(sticks) < 2: + return 99.0 + trees = [] + for g in sticks: + bm_s = bmesh.new() + bm_s.from_mesh(me) + keep = set(g) + drop = [f for f in bm_s.faces if not all(v.index in keep for v in f.verts)] + if drop: + bmesh.ops.delete(bm_s, geom=drop, context="FACES") + if bm_s.faces: + trees.append((g, BVHTree.FromBMesh(bm_s), bm_s)) + else: + bm_s.free() + best = 99.0 + try: + for i in range(len(trees)): + g, _tree, _bm = trees[i] + zmax = max(me.vertices[k].co.z for k in g) + for j in range(i + 1, len(trees)): + tree = trees[j][1] + pair = 99.0 + for k in g: + p = me.vertices[k].co + if p.z < zmax - 0.10: + continue + loc, _n, _idx, dist = tree.find_nearest(p) + if loc is None: + continue + pair = min(pair, dist) + best = min(best, pair) + return best + finally: + for _g, _t, bm_s in trees: + bm_s.free() + + +def body_plan(me): + groups = shells(me) + xs, ys, z0s, z1s = [], [], [], [] + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + a = shell_aabb(me, g) + if a[5] - a[2] < 0.05: + continue + xs.extend((a[0], a[3])) + ys.extend((a[1], a[4])) + z0s.append(a[2]) + z1s.append(a[5]) + if not xs: + return 0.0, 0.0 + dia = 0.5 * ((max(xs) - min(xs)) + (max(ys) - min(ys))) + return dia, max(z1s) - min(z0s) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, 0.20)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) lod.matrix_world = obj.matrix_world.copy() - bpy.context.scene.collection.objects.link(lod) + bpy.context.collection.objects.link(lod) if not skip_decimate and 0.0 < ratio < 1.0: mod = lod.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" @@ -720,7 +1042,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: @@ -732,6 +1053,14 @@

Source

bmesh.ops.delete(bm, geom=interior, context="VERTS") if unused: bmesh.ops.delete(bm, geom=unused, context="VERTS") + bmesh.ops.dissolve_limit( + bm, + angle_limit=math.radians(10.0), + verts=list(bm.verts), + edges=list(bm.edges), + delimit={"NORMAL"}, + ) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(mesh) mesh.update() finally: @@ -743,7 +1072,6 @@

Source

def setup_bake_image(obj, target_mat, size=BAKE_RES): - # Adapted from snippets/setup_bake_target_image.py — do not replace slots. if not obj.data.uv_layers: return None, None img = bpy.data.images.new("CampfireNrm", size, size, alpha=True, float_buffer=False) @@ -758,7 +1086,6 @@

Source

def bake_normal(high, low): - # Duplicated from snippets/bake_normal_high_to_low.py (not a package). scene = bpy.context.scene scene.render.engine = "CYCLES" scene.cycles.device = "CPU" @@ -783,7 +1110,6 @@

Source

def export_unity(path, objects): - # Duplicated from snippets/export_preset_unity.py (not a package). for ob in bpy.context.view_layer.objects: ob.select_set(False) for ob in objects: @@ -799,15 +1125,43 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_stones=False, + float_logs=False, + gap_courses=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_campfire_mesh("CampfireLow", bevel_offset=0.010, bevel_segments=2) - high = build_campfire_mesh("CampfireHigh", bevel_offset=0.010, bevel_segments=4) - stone = principled("CampfireStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84) - wood = principled("CampfireWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) - ash = principled("CampfireAsh", (0.12, 0.11, 0.10, 1.0), 0.0, 0.94) + flags = dict( + short_stones=short_stones, + float_logs=float_logs, + gap_courses=gap_courses, + ) + low = build_campfire_mesh("CampfireLow", 0.006, 2, **flags) + high = build_campfire_mesh("CampfireHigh", 0.006, 4, **flags) + stone = principled( + "CampfireStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84, + noise_scale=8.0, wear=(0.28, 0.27, 0.24, 1.0), + ) + wood = principled( + "CampfireWood", (0.38, 0.18, 0.07, 1.0), 0.0, 0.62, + noise_scale=6.0, wear=(0.18, 0.09, 0.04, 1.0), + ) + ash = principled( + "CampfireAsh", (0.12, 0.11, 0.10, 1.0), 0.0, 0.94, + noise_scale=10.0, wear=(0.08, 0.07, 0.06, 1.0), + ) assign_slots(low, stone, wood, ash) assign_slots(high, stone, wood, ash) + 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() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("campfire mesh did not build", 3), None, None, None, None, None @@ -825,6 +1179,21 @@

Source

size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + cseat = course_seat(low.data) + kiss = teepee_kiss(low.data) + dia, ht = body_plan(low.data) + 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 stones={sup['stones']} stone_z={sup['stone_z']:.5f} " + f"course_seat={cseat:.5f} kiss={kiss:.5f} body={dia:.4f}x{ht:.4f}" + ) img, tex = setup_bake_image(low, stone) if img is None: @@ -839,9 +1208,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_campfire_mesh( - "CampfireColSrc", bevel_offset=0.0, bevel_segments=1 - ) + collider_src = build_campfire_mesh("CampfireColSrc", 0.0, 1) collider = convex_hull_collider(collider_src, "CampfireCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -855,9 +1222,7 @@

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}" - ) + 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}" @@ -885,14 +1250,19 @@

Source

f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5, ), None, None, None, None, None - if idx_counts.get(WOOD_IDX, 0) < 24: + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(ASH_IDX, 0) < ASH_FACES_MIN: return fail( - f"wood log faces {idx_counts.get(WOOD_IDX, 0)} < 24", + f"ash faces {idx_counts.get(ASH_IDX, 0)} < {ASH_FACES_MIN}", 5, ), None, None, None, None, None - if idx_counts.get(ASH_IDX, 0) < 8: + if idx_counts.get(STONE_IDX, 0) < STONE_FACES_MIN: return fail( - f"ash faces {idx_counts.get(ASH_IDX, 0)} < 8", + f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}", 5, ), None, None, None, None, None if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: @@ -938,28 +1308,45 @@

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 + if bb[2] > ZMIN_EPS or sup["stones"] != BOTTOM_COUNT or sup["stone_z"] > STAVE_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} stones={sup['stones']} " + f"stone_z={sup['stone_z']:.5f}", + 16, + ), None, None, None, None, None + if kiss > KISS_MAX: + return fail(f"teepee kiss {kiss:.5f} > {KISS_MAX}", 17), None, None, None, None, None + if abs(cseat) > COURSE_SEAT_MAX: + return fail( + f"course seat {cseat:.5f} abs > {COURSE_SEAT_MAX}", + 18, + ), None, None, None, None, None + if abs(dia - RING_DIA) > BODY_TOL or abs(ht - RING_H) > BODY_TOL: + return fail( + f"ring {dia:.4f}x{ht:.4f} off {RING_DIA}x{RING_H}", + 19, + ), None, None, None, None, None return 0, low, high, stone, tex, collider -def wire_normal(mat, tex): - nt = mat.node_tree - bsdf = nt.nodes["Principled BSDF"] - nrm = nt.nodes.new("ShaderNodeNormalMap") - nrm.inputs["Strength"].default_value = 1.0 - nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) - nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) - - -def render_still(low, stone, tex, path, engine): +def render_still(low, _stone, _tex, path, engine): scene = bpy.context.scene - wire_normal(stone, tex) for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True ob.hide_viewport = True low.rotation_euler.z = math.radians(-28.0) - low.rotation_euler.x = math.radians(0.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -1002,13 +1389,15 @@

Source

light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.99, -1.42, 0.93) + cam.location = (span * 1.24, -span * 1.80, span * 1.36) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.16) + aim.location = (0.0, 0.0, 0.5 * (bb[2] + bb[5]) - 0.04 * span) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1051,14 +1440,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("--short-stones", action="store_true") + p.add_argument("--float-logs", action="store_true") + p.add_argument("--gap-courses", 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, + short_stones=args.short_stones, + float_logs=args.float_logs, + gap_courses=args.gap_courses, + ) if code: return code if args.output: diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 1fe083e..989e70b 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -925,7 +925,7 @@

wooden-barrel

campfire

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

-

witnesses Recomputed: 3616 tris, three materials with 100 wood and 48 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.789×0.789×0.410 m, LOD ratios in band, convex collider 254 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 2200 tris, three materials with 216 wood, 48 ash and 1304 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.807×0.808×0.404 m, LOD ratios in band, convex collider 198 tris, hygiene 0, 14 grounded bottom stones, teepee kiss 0.00001 m, course seat 0, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-stones 16; --float-logs 17; --gap-courses 18.

View example
@@ -1035,7 +1035,7 @@

wheelbarrow

anvil

A procedural blacksmith anvil on a timber stump through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 6616 tris, two materials with 2616 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.772×0.419×0.662 m, LOD ratios in band, convex collider 190 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 2988 tris, two materials with 1040 wood and 822 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.621×0.414×0.489 m, LOD ratios in band, convex collider 134 tris, hygiene 0, 16 grounded staves, hoop bite 0.003 m, foot-on-head gap 0.003 m, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-staves 16; --float-anvil 17; --round-band 18.

View example
diff --git a/showcase/campfire/README.md b/showcase/campfire/README.md index 67e304f..bd54875 100644 --- a/showcase/campfire/README.md +++ b/showcase/campfire/README.md @@ -1,10 +1,15 @@ # Campfire A showcase piece, not an example. Procedural campfire (two-course -running-bond stone ring, pit-floor cobbles, ash, a log tripod, and -charcoal) then the shipped pipeline: unique-cell UVs, Cycles +running-bond stone ring of 14 barrelled wedges, pit-floor cobbles, ash, +a kissing log tripod that overshoots a shared apex ring, floor logs, +and charcoal) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export. +Courses share a Z plane; `--gap-courses` is the falsifier. Teepee axes +aim at a ring of radius `(STICK_R * 0.55) / sin(π/N)` and overshoot so +the caps cross; `--float-logs` inflates that ring. + It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -21,38 +26,83 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 3500–3750 | 3616 / 3616 / 3616 | +| Base triangles | 1400–3600 | 2200 / 2200 / 2200 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | -| Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 100 wood, 48 ash | +| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 | +| Materials | exactly 3 distinct; ≥80 wood, ≥24 ash, ≥200 stone | 3 slots; 216 / 48 / 1304 | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (0.789, 0.789, 0.410) m ± 0.01 | (0.7891, 0.7891, 0.4094), zmin 0 | -| Collider tris | ≤ 320 | 254 | -| Export | written, size > 0 | 273604 / 273604 / 273596 bytes | +| Outer AABB | (0.807, 0.808, 0.404) m ± 0.015 | (0.8069, 0.8079, 0.4037), zmin 0 | +| Collider tris | ≤ 360 | 198 | +| Export | written, size > 0 | 206244 / 206244 / 206236 bytes | + +Base triangles dropped from **3616 to 2200** in the quality pass: the +old piece stacked identical boxes with a mortar gap and a teepee that +met at one point. The rebuild is two seated courses plus crossing +sticks; hidden overlapping volume went away. DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across -series — the gate is a ratio band, not an exact count. This mesh happened -to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate -is `has_data` plus operator `FINISHED`, not byte-identity. Construction -uses no RNG. glTF byte size differs by a few bytes across series. +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. Stone-width jitter uses fixed seed 17. glTF byte size +differs by 8 B on 5.2.1. + +### 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 supports: 14 bottom stones | each `zmin` ≤ 0.001 | 14, stone_z 0.00000 | +| Ring plan | 0.807 m dia × 0.156 m high ± 0.04 | 0.8074 × 0.1560 | -`--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. +### Joint fit and seat + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Teepee kiss (BVH nearest at caps) | ≤ 0.008 m | 0.00001 | +| Course seat (upper zmin − lower zmax) | abs ≤ 0.004 m | 0.00000 | + +### Falsifiers + +Each violates one named budget. All six 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-stones` | named bottom-course supports at Z=0 | 16 | +| `--float-logs` | teepee kiss | 17 | +| `--gap-courses` | course seat | 18 | ## Run ```bash blender --background --python campfire.py -- blender --background --python campfire.py -- --skip-decimate +blender --background --python campfire.py -- --stray-vert +blender --background --python campfire.py -- --lift-z +blender --background --python campfire.py -- --short-stones +blender --background --python campfire.py -- --float-logs +blender --background --python campfire.py -- --gap-courses blender --background --python campfire.py -- --output campfire.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 | | --- | --- | @@ -61,7 +111,7 @@ File-local. `9` is a valid check code. `10` is reserved for | 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/ash faces missing | +| 5 | Material count ≠ 3 distinct slots, or a face-count floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | @@ -71,3 +121,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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | +| 16 | Not grounded: bounding box `zmin` off 0, or a named bottom stone floats | +| 17 | Joint fit: teepee kiss (`--float-logs`) | +| 18 | Seat: course gap (`--gap-courses`) | +| 19 | Ring diameter or height off the stated real-world size | diff --git a/showcase/campfire/campfire.py b/showcase/campfire/campfire.py index d332045..9711405 100644 --- a/showcase/campfire/campfire.py +++ b/showcase/campfire/campfire.py @@ -1,14 +1,23 @@ """Game-ready campfire — a showcase piece, not an example. -Asserts budget conformance of a procedural campfire after composing -shipped pipeline pieces: bmesh construction, UVs, three materials, -high-to-low normal bake, LOD chain, convex collider, Unity glTF export. +Asserts budget conformance of a procedural campfire (two-course +wedge-stone ring stacked on a shared plane, pit-floor cobbles and ash, +a kissing log tripod, and resting firewood) after composing shipped +pipeline pieces: bmesh construction, UVs, three materials, high-to-low +normal bake, LOD chain, convex collider, Unity glTF export. + +The old piece was two floating courses of identical boxes with a +daylight mortar gap, a 12-gon teepee whose tips occupied the same +point, and an empty pit. 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. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-stones`` named +bottom-course supports, ``--float-logs`` teepee kiss, ``--gap-courses`` +the stacked-course seat. -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts +Fixed seed 17 for stone-width jitter. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a ratio band, not an exact count. @@ -19,17 +28,16 @@ import argparse import math import os +import random import sys import tempfile import traceback import bmesh import bpy -from mathutils import Euler, Vector +from mathutils import 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,28 +45,47 @@ sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -N_AROUND = 12 +N_AROUND = 14 N_ROWS = 2 -R_INNER = 0.28 -STONE_D = 0.11 -R_OUTER = R_INNER + STONE_D -R_MID = (R_INNER + R_OUTER) / 2.0 -WALL_H = 0.155 -STONE_H = WALL_H / N_ROWS -N_FLOOR = 6 -N_ASH = 7 -N_LOGS = 2 -LOG_LEN = 0.34 -LOG_R = 0.032 +R_INNER = 0.26 +R_OUTER = 0.39 +R_MID = 0.5 * (R_INNER + R_OUTER) +STONE_H = 0.078 +STONE_SEED = 17 +STONE_JITTER = 0.10 +GAP_ANG = 0.004 +N_COBBLES = 12 +COBBLE_H = 0.016 +ASH_H = 0.010 +ASH_R = 0.20 N_STICKS = 3 -STICK_R = 0.030 -N_COALS = 5 -BBOX_TOL = 0.01 -# Fitted to the generated AABB after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (0.789, 0.789, 0.410) - -BASE_TRIS_MIN = 3500 -BASE_TRIS_MAX = 3750 +STICK_R = 0.028 +STICK_SEGS = 12 +LOG_R = 0.030 +LOG_LEN = 0.28 +Z_APEX = 0.365 +R_BASE = 0.20 +COURSE_GAP = 0.022 +FLOAT_LOG = 0.055 +SHORT_STONES_LIFT = 0.040 +LIFT_Z = 0.05 +KISS_MAX = 0.008 +COURSE_SEAT_MAX = 0.004 +STAVE_ZMIN_MAX = 0.001 + +AREA_EPS = 1e-10 +DOUBLES_EPS = 1e-5 +ZMIN_EPS = 1e-4 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +BODY_TOL = 0.04 +RING_DIA = 0.807 +RING_H = 0.156 +BBOX_TOL = 0.015 +OUTER_SIZE = (0.807, 0.808, 0.404) + +BASE_TRIS_MIN = 1400 +BASE_TRIS_MAX = 3600 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -66,11 +93,15 @@ LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 3 +WOOD_FACES_MIN = 80 +ASH_FACES_MIN = 24 +STONE_FACES_MIN = 200 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 320 +COLLIDER_TRIS_MAX = 360 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 +BOTTOM_COUNT = N_AROUND STONE_IDX = 0 WOOD_IDX = 1 @@ -92,7 +123,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() @@ -103,66 +133,113 @@ def evaluated_triangle_count(obj): eval_obj.to_mesh_clear() -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"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) - v.co = rot @ p + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx +def stone_spans(n, gap_ang, jitter, seed): + rng = random.Random(seed) + weights = [1.0 + rng.uniform(-jitter, jitter) for _ in range(n)] + total = sum(weights) + usable = 2.0 * math.pi - n * gap_ang + spans = [] + a = 0.0 + for w in weights: + width = usable * (w / total) + spans.append((a, a + width)) + a += width + gap_ang + return spans + + +def add_wedge(bm, a0, a1, r_in, r_out, z0, z1, mat_idx, lump=0.0): + mid_z = 0.5 * (z0 + z1) + mid_ang = 0.5 * (a0 + a1) + layers = ( + (z0, 0.985, 0.0), + (mid_z, 1.025, lump), + (z1, 0.97, 0.0), + ) + rings = [] + for z, r_scale, lp in layers: + ring = [] + for ang, r in ( + (a0, r_in), + (a1, r_in), + (a1, r_out * r_scale), + (a0, r_out * r_scale), + ): + x = r * math.cos(ang) + y = r * math.sin(ang) + if lp > 0.0 and r > 0.5 * (r_in + r_out): + h = math.sin(ang * 5.0 + z * 17.0 + mid_ang * 3.0) + scale = 1.0 + lp * max(-0.7, min(0.7, h)) + x *= scale + y *= scale + ring.append(bm.verts.new((x, y, z))) + rings.append(ring) + verts = [v for ring in rings for v in ring] + for k in range(len(rings) - 1): + a, b = rings[k], rings[k + 1] + for i in range(4): + j = (i + 1) % 4 + face = bm.faces.new((a[i], a[j], b[j], b[i])) + face.material_index = mat_idx + bot = rings[0] + top = rings[-1] + face = bm.faces.new((bot[0], bot[3], bot[2], bot[1])) + face.material_index = mat_idx + face = bm.faces.new((top[0], top[1], top[2], top[3])) + face.material_index = mat_idx return verts -def add_rock(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0), lump=0.0): - # Bevelled boxes, same language as stone-well masonry — not icospheres. - # Subdiv-1 icos read as d20s at thumbnail regardless of smooth shading. - verts = add_box(bm, loc, scale, mat_idx, euler) - if lump <= 0.0: - return verts - origin = Vector(loc) - rot = Euler(euler).to_matrix() - inv = rot.inverted() +def add_cyl_between(bm, a, b, r0, r1, segs, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=True, + segments=segs, + radius1=r0, + radius2=r1, + depth=length, + ) + verts = list(geo["verts"]) + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + rot = quat.to_matrix() + mid = (a + b) * 0.5 for v in verts: - local = inv @ (v.co - origin) - h = math.sin(local.x * 15.7 + local.y * 11.3 + local.z * 9.1) - h += 0.35 * math.cos(local.y * 21.0 + local.x * 8.0) - h = max(-0.85, min(0.85, h)) - local = Vector(( - local.x * (1.0 + lump * h), - local.y * (1.0 + lump * 0.65 * math.sin(local.z * 13.0 + 1.2)), - local.z * (1.0 + lump * 0.40 * h), - )) - v.co = rot @ local + origin + v.co = rot @ v.co + mid + 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)): +def add_ash_disk(bm, radius, height, segs, mat_idx): geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + cap_tris=True, + segments=segs, + radius1=radius, + radius2=radius * 0.92, + depth=height, ) - verts = geo["verts"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) + verts = list(geo["verts"]) for v in verts: - v.co = rot @ v.co + origin + v.co.z += height * 0.5 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_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 triangulate_ngons(bm): + faces = [f for f in bm.faces if len(f.verts) > 4] + if faces: + bmesh.ops.triangulate(bm, faces=faces) def pack_uvs(bm, margin=0.08): @@ -208,141 +285,145 @@ def pack_uvs(bm, margin=0.08): ) -def build_campfire_mesh(name, bevel_offset, bevel_segments): +def build_campfire_mesh( + name, + bevel_offset, + bevel_segments, + short_stones=False, + float_logs=False, + gap_courses=False, +): bm = bmesh.new() + spans = stone_spans(N_AROUND, GAP_ANG, STONE_JITTER, STONE_SEED) + z_ground = SHORT_STONES_LIFT if short_stones else 0.0 + extra = COURSE_GAP if gap_courses else 0.0 + stone_verts = [] try: - # Two-course running-bond ring — same masonry as stone-well, not a - # circle of cubes or icos. Bevel stone edges only; logs go on after. - stone_verts = [] - stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88 - actual_h = STONE_H * 0.90 for row in range(N_ROWS): - z = actual_h / 2.0 + row * STONE_H + z0 = z_ground + row * STONE_H + (extra if row else 0.0) + z1 = z0 + STONE_H rot_off = (row % 2) * (math.pi / N_AROUND) - for i in range(N_AROUND): - ang = 2.0 * math.pi * i / N_AROUND + rot_off - loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z) + for a0, a1 in spans: stone_verts.extend( - add_box( + add_wedge( bm, - loc, - (STONE_D, stone_w, actual_h), + a0 + rot_off, + a1 + rot_off, + R_INNER, + R_OUTER, + z0, + z1, STONE_IDX, - euler=(0.0, 0.0, ang), + lump=0.022, ) ) - for i in range(N_FLOOR): - ang = 2.0 * math.pi * i / N_FLOOR + 0.22 - r = 0.11 + 0.02 * (i % 2) - sz = 0.032 + 0.008 * (i % 3) - loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50) - stone_verts.extend( - add_box( + if bevel_offset > 0.0: + vertical = [] + seen = set() + for v in stone_verts: + for e in v.link_edges: + if e in seen: + continue + seen.add(e) + a, b = e.verts + if abs(a.co.z - b.co.z) > 0.02: + vertical.append(e) + if vertical: + ret = bmesh.ops.bevel( bm, - loc, - (0.078, 0.058, sz), - STONE_IDX, - euler=(0.0, 0.0, ang), + geom=vertical, + offset=min(bevel_offset, 0.008), + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, ) - ) - - if bevel_offset > 0.0: - edges = list({e for v in stone_verts for e in v.link_edges}) - bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - - add_rock( - bm, - (0.0, 0.0, 0.014), - (0.10, 0.09, 0.024), - ASH_IDX, - lump=0.14, - ) - for i in range(N_ASH): - ang = 2.0 * math.pi * i / N_ASH + 0.18 - r = 0.022 + 0.040 * ((i % 4) / 3.0) - sz = 0.016 + 0.010 * abs(math.sin(i * 1.7)) - loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50) - add_rock( - bm, - loc, - (0.032 + 0.006 * (i % 3), 0.026, sz), - ASH_IDX, - euler=(0.0, 0.0, ang), - lump=0.16, - ) - - for i in range(N_LOGS): - yaw = i * (math.pi / 2.0) + math.radians(18.0) - loc = ( - 0.04 * math.cos(yaw + math.pi / 2.0), - 0.04 * math.sin(yaw + math.pi / 2.0), - LOG_R + 0.018 + 0.012 * i, - ) - add_cone( - bm, - loc, - LOG_R * (1.0 - 0.04 * i), - LOG_R * 0.88, - LOG_LEN, - 12, - WOOD_IDX, - euler=(math.pi / 2.0, 0.0, yaw), - ) - - # Teepee aimed at a shared apex so the sticks read as one fire, not - # four independent posts. Cone local +Z tracks base -> apex. - z_apex = WALL_H + 0.24 - z_base = 0.040 - r_base = R_INNER - 0.05 + for f in ret.get("faces") or []: + f.material_index = STONE_IDX + + add_ash_disk(bm, ASH_R, ASH_H, 16, ASH_IDX) + rng = random.Random(STONE_SEED) + for i in range(N_COBBLES): + ang = 2.0 * math.pi * i / N_COBBLES + 0.18 + r = 0.07 + 0.05 * ((i % 3) / 2.0) + sz = 0.028 + 0.010 * (i % 2) + loc = Vector((r * math.cos(ang), r * math.sin(ang), COBBLE_H * 0.5)) + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + for v in verts: + p = Vector(( + v.co.x * (sz * 1.4), + v.co.y * sz, + v.co.z * COBBLE_H, + )) + h = rng.uniform(-0.12, 0.12) + p = Vector((p.x * (1.0 + h), p.y * (1.0 - 0.5 * h), p.z)) + rot_z = ang + rng.uniform(-0.2, 0.2) + c, s = math.cos(rot_z), math.sin(rot_z) + v.co = Vector(( + p.x * c - p.y * s, + p.x * s + p.y * c, + p.z, + )) + loc + for f in {f for v in verts for f in v.link_faces}: + f.material_index = STONE_IDX + + apex_ring = (STICK_R * 0.55) / math.sin(math.pi / N_STICKS) + if float_logs: + apex_ring *= 2.8 + z_base = COBBLE_H + STICK_R * 0.35 + overshoot = STICK_R * 1.15 for i in range(N_STICKS): - yaw = i * (2.0 * math.pi / N_STICKS) + math.pi / 2.0 + yaw = i * (2.0 * math.pi / N_STICKS) + math.pi / 6.0 base = Vector(( - r_base * math.cos(yaw), - r_base * math.sin(yaw), + R_BASE * math.cos(yaw), + R_BASE * math.sin(yaw), z_base, )) - apex = Vector((0.0, 0.0, z_apex)) - delta = apex - base - mid = (base + apex) * 0.5 - rot = delta.to_track_quat("Z", "Y").to_euler() - add_cone( - bm, - (mid.x, mid.y, mid.z), - STICK_R, - STICK_R * 0.88, - delta.length, - 12, - WOOD_IDX, - euler=(rot.x, rot.y, rot.z), - ) - - for i in range(N_COALS): - ang = 2.0 * math.pi * i / N_COALS + 0.11 - r = 0.030 + 0.022 * (i % 3) - sz = 0.014 + 0.008 * abs(math.sin(i * 2.2)) - loc = (r * math.cos(ang), r * math.sin(ang), 0.022 + sz / 2.0) - add_rock( - bm, - loc, - (0.026, 0.020, sz), - WOOD_IDX, - euler=(0.0, 0.0, ang), - lump=0.18, + apex = Vector(( + apex_ring * math.cos(yaw), + apex_ring * math.sin(yaw), + Z_APEX - i * (STICK_R * 0.55), + )) + direction = (apex - base).normalized() + end = apex + direction * overshoot + add_cyl_between( + bm, base, end, STICK_R, STICK_R * 0.78, STICK_SEGS, WOOD_IDX, ) - for v in bm.verts: - if v.co.z < 0.0: - v.co.z = 0.0 + for i in range(2): + yaw = i * (math.pi / 2.0) + math.radians(22.0) + z = COBBLE_H + LOG_R + 0.010 * i + a = Vector(( + 0.5 * LOG_LEN * math.cos(yaw), + 0.5 * LOG_LEN * math.sin(yaw), + z, + )) + b = Vector((-a.x, -a.y, z)) + add_cyl_between(bm, a, b, LOG_R * (1.0 - 0.06 * i), LOG_R * 0.86, 12, WOOD_IDX) + for i in range(6): + ang = 2.0 * math.pi * i / 6.0 + 0.4 + r = 0.04 + 0.02 * (i % 2) + sz = 0.018 + 0.006 * (i % 3) + loc = ( + r * math.cos(ang), + r * math.sin(ang), + ASH_H + sz * 0.45, + ) + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + for v in verts: + p = Vector((v.co.x * sz * 1.3, v.co.y * sz, v.co.z * sz * 0.7)) + c, s = math.cos(ang), math.sin(ang) + v.co = Vector((p.x * c - p.y * s, p.x * s + p.y * c, p.z)) + Vector(loc) + for f in {f for v in verts for f in v.link_faces}: + f.material_index = WOOD_IDX + + triangulate_ngons(bm) + bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5) + bmesh.ops.dissolve_degenerate(bm, dist=1e-6) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: @@ -368,19 +449,37 @@ def build_campfire_mesh(name, bevel_offset, bevel_segments): return obj -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 = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + 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"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.16) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat def assign_slots(obj, stone, wood, ash): - # Do not materials.clear() — that resets polygon material_index to 0 - # on this Blender, which would drop wood/ash faces onto stone. mats = obj.data.materials wanted = (stone, wood, ash) for i, mat in enumerate(wanted): @@ -398,6 +497,17 @@ def world_bbox(obj): return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) +def face_area(me, poly): + verts = [me.vertices[i].co for i in poly.vertices] + if len(verts) < 3: + return 0.0 + acc = Vector((0.0, 0.0, 0.0)) + origin = verts[0] + for a, b in zip(verts[1:], verts[2:]): + acc += (a - origin).cross(b - origin) + return 0.5 * acc.length + + def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: @@ -423,11 +533,209 @@ def uv_stats(mesh): return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +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) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + 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 p in me.polygons: + if all(i in member for i in p.vertices): + return p.material_index + return None + + +def support_audit(me): + groups = shells(me) + bottoms = [] + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + a = shell_aabb(me, g) + dz = a[5] - a[2] + r = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if dz > 0.05 and r > 0.04 and a[2] < 0.02: + bottoms.append(a) + zmin = min((a[2] for a in bottoms), default=99.0) + return {"stones": len(bottoms), "stone_z": zmin} + + +def course_seat(me): + groups = shells(me) + lower, upper = [], [] + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + a = shell_aabb(me, g) + dz = a[5] - a[2] + r = 0.25 * ((a[3] - a[0]) + (a[4] - a[1])) + if dz < 0.05 or r < 0.04: + continue + mid = 0.5 * (a[2] + a[5]) + if mid < STONE_H: + lower.append(a) + else: + upper.append(a) + if not lower or not upper: + return 99.0 + return min(a[2] for a in upper) - max(a[5] for a in lower) + + +def teepee_kiss(me): + groups = shells(me) + sticks = [] + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + if a[5] < 0.25: + continue + sticks.append(g) + if len(sticks) < 2: + return 99.0 + trees = [] + for g in sticks: + bm_s = bmesh.new() + bm_s.from_mesh(me) + keep = set(g) + drop = [f for f in bm_s.faces if not all(v.index in keep for v in f.verts)] + if drop: + bmesh.ops.delete(bm_s, geom=drop, context="FACES") + if bm_s.faces: + trees.append((g, BVHTree.FromBMesh(bm_s), bm_s)) + else: + bm_s.free() + best = 99.0 + try: + for i in range(len(trees)): + g, _tree, _bm = trees[i] + zmax = max(me.vertices[k].co.z for k in g) + for j in range(i + 1, len(trees)): + tree = trees[j][1] + pair = 99.0 + for k in g: + p = me.vertices[k].co + if p.z < zmax - 0.10: + continue + loc, _n, _idx, dist = tree.find_nearest(p) + if loc is None: + continue + pair = min(pair, dist) + best = min(best, pair) + return best + finally: + for _g, _t, bm_s in trees: + bm_s.free() + + +def body_plan(me): + groups = shells(me) + xs, ys, z0s, z1s = [], [], [], [] + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + a = shell_aabb(me, g) + if a[5] - a[2] < 0.05: + continue + xs.extend((a[0], a[3])) + ys.extend((a[1], a[4])) + z0s.append(a[2]) + z1s.append(a[5]) + if not xs: + return 0.0, 0.0 + dia = 0.5 * ((max(xs) - min(xs)) + (max(ys) - min(ys))) + return dia, max(z1s) - min(z0s) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, 0.20)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) lod.matrix_world = obj.matrix_world.copy() - bpy.context.scene.collection.objects.link(lod) + bpy.context.collection.objects.link(lod) if not skip_decimate and 0.0 < ratio < 1.0: mod = lod.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" @@ -436,7 +744,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: @@ -448,6 +755,14 @@ def convex_hull_collider(obj, name): bmesh.ops.delete(bm, geom=interior, context="VERTS") if unused: bmesh.ops.delete(bm, geom=unused, context="VERTS") + bmesh.ops.dissolve_limit( + bm, + angle_limit=math.radians(10.0), + verts=list(bm.verts), + edges=list(bm.edges), + delimit={"NORMAL"}, + ) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(mesh) mesh.update() finally: @@ -459,7 +774,6 @@ def convex_hull_collider(obj, name): def setup_bake_image(obj, target_mat, size=BAKE_RES): - # Adapted from snippets/setup_bake_target_image.py — do not replace slots. if not obj.data.uv_layers: return None, None img = bpy.data.images.new("CampfireNrm", size, size, alpha=True, float_buffer=False) @@ -474,7 +788,6 @@ def setup_bake_image(obj, target_mat, size=BAKE_RES): def bake_normal(high, low): - # Duplicated from snippets/bake_normal_high_to_low.py (not a package). scene = bpy.context.scene scene.render.engine = "CYCLES" scene.cycles.device = "CPU" @@ -499,7 +812,6 @@ def bake_normal(high, low): def export_unity(path, objects): - # Duplicated from snippets/export_preset_unity.py (not a package). for ob in bpy.context.view_layer.objects: ob.select_set(False) for ob in objects: @@ -515,15 +827,43 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_stones=False, + float_logs=False, + gap_courses=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_campfire_mesh("CampfireLow", bevel_offset=0.010, bevel_segments=2) - high = build_campfire_mesh("CampfireHigh", bevel_offset=0.010, bevel_segments=4) - stone = principled("CampfireStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84) - wood = principled("CampfireWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) - ash = principled("CampfireAsh", (0.12, 0.11, 0.10, 1.0), 0.0, 0.94) + flags = dict( + short_stones=short_stones, + float_logs=float_logs, + gap_courses=gap_courses, + ) + low = build_campfire_mesh("CampfireLow", 0.006, 2, **flags) + high = build_campfire_mesh("CampfireHigh", 0.006, 4, **flags) + stone = principled( + "CampfireStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84, + noise_scale=8.0, wear=(0.28, 0.27, 0.24, 1.0), + ) + wood = principled( + "CampfireWood", (0.38, 0.18, 0.07, 1.0), 0.0, 0.62, + noise_scale=6.0, wear=(0.18, 0.09, 0.04, 1.0), + ) + ash = principled( + "CampfireAsh", (0.12, 0.11, 0.10, 1.0), 0.0, 0.94, + noise_scale=10.0, wear=(0.08, 0.07, 0.06, 1.0), + ) assign_slots(low, stone, wood, ash) assign_slots(high, stone, wood, ash) + 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() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("campfire mesh did not build", 3), None, None, None, None, None @@ -541,6 +881,21 @@ def check(skip_decimate): size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + cseat = course_seat(low.data) + kiss = teepee_kiss(low.data) + dia, ht = body_plan(low.data) + 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 stones={sup['stones']} stone_z={sup['stone_z']:.5f} " + f"course_seat={cseat:.5f} kiss={kiss:.5f} body={dia:.4f}x{ht:.4f}" + ) img, tex = setup_bake_image(low, stone) if img is None: @@ -555,9 +910,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_campfire_mesh( - "CampfireColSrc", bevel_offset=0.0, bevel_segments=1 - ) + collider_src = build_campfire_mesh("CampfireColSrc", 0.0, 1) collider = convex_hull_collider(collider_src, "CampfireCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -571,9 +924,7 @@ 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}" - ) + 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}" @@ -601,14 +952,19 @@ def check(skip_decimate): f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5, ), None, None, None, None, None - if idx_counts.get(WOOD_IDX, 0) < 24: + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(ASH_IDX, 0) < ASH_FACES_MIN: return fail( - f"wood log faces {idx_counts.get(WOOD_IDX, 0)} < 24", + f"ash faces {idx_counts.get(ASH_IDX, 0)} < {ASH_FACES_MIN}", 5, ), None, None, None, None, None - if idx_counts.get(ASH_IDX, 0) < 8: + if idx_counts.get(STONE_IDX, 0) < STONE_FACES_MIN: return fail( - f"ash faces {idx_counts.get(ASH_IDX, 0)} < 8", + f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}", 5, ), None, None, None, None, None if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: @@ -654,28 +1010,45 @@ 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 + if bb[2] > ZMIN_EPS or sup["stones"] != BOTTOM_COUNT or sup["stone_z"] > STAVE_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} stones={sup['stones']} " + f"stone_z={sup['stone_z']:.5f}", + 16, + ), None, None, None, None, None + if kiss > KISS_MAX: + return fail(f"teepee kiss {kiss:.5f} > {KISS_MAX}", 17), None, None, None, None, None + if abs(cseat) > COURSE_SEAT_MAX: + return fail( + f"course seat {cseat:.5f} abs > {COURSE_SEAT_MAX}", + 18, + ), None, None, None, None, None + if abs(dia - RING_DIA) > BODY_TOL or abs(ht - RING_H) > BODY_TOL: + return fail( + f"ring {dia:.4f}x{ht:.4f} off {RING_DIA}x{RING_H}", + 19, + ), None, None, None, None, None return 0, low, high, stone, tex, collider -def wire_normal(mat, tex): - nt = mat.node_tree - bsdf = nt.nodes["Principled BSDF"] - nrm = nt.nodes.new("ShaderNodeNormalMap") - nrm.inputs["Strength"].default_value = 1.0 - nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) - nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) - - -def render_still(low, stone, tex, path, engine): +def render_still(low, _stone, _tex, path, engine): scene = bpy.context.scene - wire_normal(stone, tex) for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True ob.hide_viewport = True low.rotation_euler.z = math.radians(-28.0) - low.rotation_euler.x = math.radians(0.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -718,13 +1091,15 @@ def light(name, loc, energy, size, col, rot): light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.99, -1.42, 0.93) + cam.location = (span * 1.24, -span * 1.80, span * 1.36) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.16) + aim.location = (0.0, 0.0, 0.5 * (bb[2] + bb[5]) - 0.04 * span) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -767,14 +1142,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("--short-stones", action="store_true") + p.add_argument("--float-logs", action="store_true") + p.add_argument("--gap-courses", 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, + short_stones=args.short_stones, + float_logs=args.float_logs, + gap_courses=args.gap_courses, + ) if code: return code if args.output: diff --git a/showcase/campfire/preview.webp b/showcase/campfire/preview.webp index 90e420127d91f79f8e5ed673aa9daa05bcd5b2d3..9f1d0ac408627984cfa3dca23f0fa6126229373b 100644 GIT binary patch literal 36024 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 Izy@3Z0FQU(3jhEB literal 17670 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 F0046+Q@a2F diff --git a/showcase/gallery.json b/showcase/gallery.json index 7cff48a..4de7eb5 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -45,7 +45,7 @@ "name": "campfire", "dir": "showcase/campfire", "teaches": "A procedural campfire through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 3616 tris, three materials with 100 wood and 48 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.789×0.789×0.410 m, LOD ratios in band, convex collider 254 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 2200 tris, three materials with 216 wood, 48 ash and 1304 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.807×0.808×0.404 m, LOD ratios in band, convex collider 198 tris, hygiene 0, 14 grounded bottom stones, teepee kiss 0.00001 m, course seat 0, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-stones 16; --float-logs 17; --gap-courses 18.", "hero": "docs/gallery/assets/campfire-hero.webp", "preview": "showcase/campfire/preview.webp", "tags": [ @@ -165,7 +165,7 @@ "name": "anvil", "dir": "showcase/anvil", "teaches": "A procedural blacksmith anvil on a timber stump through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 6616 tris, two materials with 2616 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.772×0.419×0.662 m, LOD ratios in band, convex collider 190 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 2988 tris, two materials with 1040 wood and 822 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.621×0.414×0.489 m, LOD ratios in band, convex collider 134 tris, hygiene 0, 16 grounded staves, hoop bite 0.003 m, foot-on-head gap 0.003 m, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-staves 16; --float-anvil 17; --round-band 18.", "hero": "docs/gallery/assets/anvil-hero.webp", "preview": "showcase/anvil/preview.webp", "tags": [