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 @@
Rendered headless by the example itself — click to zoom.
-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).
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.
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 |
| 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 |
+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 |
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.
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. 15–19 are the hygiene and joint-fit family.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 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 |
"""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
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. 15–19 are the hygiene and joint-fit family.
+| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots, or 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": [