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 d9cfb04..39b4216 100644 Binary files a/docs/gallery/assets/anvil-hero.webp and b/docs/gallery/assets/anvil-hero.webp differ diff --git a/docs/gallery/assets/campfire-hero.webp b/docs/gallery/assets/campfire-hero.webp index 3cf3e73..9f1d0ac 100644 Binary files a/docs/gallery/assets/campfire-hero.webp and b/docs/gallery/assets/campfire-hero.webp differ 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/anvil/README.md b/showcase/anvil/README.md index f9e3732..b56e777 100644 --- a/showcase/anvil/README.md +++ b/showcase/anvil/README.md @@ -1,10 +1,17 @@ # Anvil A showcase piece, not an example. Procedural London-pattern anvil on a -timber stump (horn, face, heel, waist, iron hoops) then the shipped +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. @@ -21,39 +28,83 @@ 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 | +| 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.2198 / 0.2198 / 0.2198 | -| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 628 wood, 2616 metal | +| 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.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 | +| 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 da6fc1a..39b4216 100644 Binary files a/showcase/anvil/preview.webp and b/showcase/anvil/preview.webp differ 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 90e4201..9f1d0ac 100644 Binary files a/showcase/campfire/preview.webp and b/showcase/campfire/preview.webp differ 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": [