shipping-crate
A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
A procedural masonry archway — coursed piers, projecting imposts, nine voussoirs and a proud keystone — carried through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
Rendered headless by the showcase piece itself — click to zoom.
category Village
blender --background --python showcase/stone-archway/stone_archway.py --
A freestanding masonry arch — two coursed piers, projecting imposts, nine voussoirs turning a semicircle, and a keystone standing proud at the crown, every joint filled with a recessed mortar bed. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.
| Shipped content | Used for |
|---|---|
skills/mesh-editing-and-bmesh | wedge and box construction, chamfer, UVs in one bmesh |
skills/procedural-materials-and-shaders | ashlar, dressed stone and mortar: object-space mottling, speckle, bump, per-block tone |
skills/bake-high-to-low | Cycles tangent-space normal bake, high onto low |
skills/engine-export-presets | Unity glTF (export_yup=True) |
skills/depsgraph-and-evaluated-data | evaluated triangle counts for the LOD ratios |
snippets/decimate_to_budget.py | LOD1 / LOD2 COLLAPSE chain |
snippets/convex_hull_collider.py | convex collider |
snippets/lod_chain.py | LOD naming and ratio pattern |
examples/mesh-hygiene-audit | hygiene combinatorics (copied, not imported) |
An arch can fail invisibly. Nine wedge blocks laid in a row are still nine wedge blocks; the thing that makes them an arch is that their intrados vertices sit on a circle. So the piece recomputes that circle from vertex positions — not from the angles the generator used — and asserts every intrados vertex lands within 4 mm of the declared 0.60 m radius, over an arc spanning at least 168°.
--off-circle is the falsifier built for exactly this. It keeps the angles, the joints, the materials, the triangle count and the bounding box identical, and only wanders the intrados radius by ±18 mm. Every other budget in the piece still passes. Only the circle fit sees it.
The first attempt selected intrados vertices by radius. That swept in the chamfer vertices sitting one bevel-width out on each radial face and reported a 5.1 mm error on a true arch. The fit now runs over the vertices of faces that actually face the springing centre — normal inward in XZ and with a small Y component, which is what excludes the chamfer strips running along the intrados edges.
Declared in the script as named constants, recomputed from the generated mesh. Measured values are from Blender 5.2.1; the cross-version table is at the end.
| Budget | Band | Measured | ||
|---|---|---|---|---|
| Base triangles | 950–1450 | 1140 | ||
| LOD1 ratio | 0.32–0.62 | 0.5000 | ||
| LOD2 ratio | 0.10–0.35 | 0.2140 (5.2) / 0.2193 (4.5, 5.1) | ||
| Material slots | exactly 3, distinct | 3 | ||
| Dressed faces (keystone, imposts, plinth and head courses) | ≥ 100 | 182 | ||
| Ashlar faces | ≥ 280 | 364 | ||
| Mortar faces | ≥ 100 | 108 | ||
| UV bounds | inside 0..1 | (0.0015, 0.0015)–(0.9985, 0.9985) | ||
| UV AABB overlap | ≤ 1e-5 | 0.000000 | ||
| Outer AABB | 1.644 × 0.510 × 2.057 m ± 0.020 | 1.6440 × 0.5100 × 2.0568 | ||
| Clear opening | 1.200 m ± 0.015, measured between the pier faces | 1.2033 | ||
| Collider triangles | ≤ 260 | 168 | ||
| Normal bake | {'FINISHED'} with image data | {'FINISHED'}, has_data=True | ||
| glTF export | file written, non-empty | ~100 kB | ||
| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar disjoint pairs 0 | ||
| Grounded AABB | \ | zmin\ | ≤ 1e-4 | 0.00000 |
| Pier supports | 2 piers, each base course zmin ≤ 1e-3 | 2 at 0.00000 | ||
| Keystone proud of the wall face | ≥ 0.020 m | 0.03500 | ||
| Springing joint | overlap, surface gap ≤ 1e-4 | 0.00000 | ||
| Mortar joints | every adjacent pair in 0.006–0.017 m | all eight at 0.00970 | ||
| Intrados circle fit | every vertex within 0.004 m of R = 0.60 | 0.00097 | ||
| Intrados arc span | ≥ 168° | 178.0° | ||
| Mortar contact | 18 joints, each overlapping exactly 2 stones | 18, all 2 |
Real-world size: a 1.20 m clear opening under a semicircular head, 1.64 m across the piers and 2.06 m to the top of the keystone — a garden gate arch.
Every joint used to be air. The pier courses stood 7 mm apart and the voussoirs 9.7 mm apart, none of them touching: each pier was a stack of floating blocks, with daylight through every joint in the ground-contact and joint close-ups. The mortar-band budget measured the gaps and passed, because the gaps were the right width. Nothing asked what was in them.
Each joint now holds a mortar shell sized from the two blocks it sits between (add_mortar): beds between courses and under each impost, and wedges between voussoirs. Each is recessed MORTAR_RECESS (chamfer plus 3 mm) behind the stone faces, so it reads as a raked joint, and bites MORTAR_BITE (1.5 mm) into both blocks. Mortar is added after the chamfer pass and left unchamfered, and classify skips mortar shells, so every stone budget — circle fit, joint band, keystone, supports — still measures stone against stone.
mortar_audit asserts that each of the 18 mortar shells BVH-overlaps exactly two stones. --short-mortar stops every joint 1 mm shy of both blocks, so each shell touches none, and the piece exits 18.
The stone used to read as wood. Its noise-driven colour mix streaked at hero scale into long brown grain, and under neutral light it looked like cream plaster. stone_material now samples object space with isotropic noise: mottling for colour, fine speckle driving roughness, and a small bump for pitting. A seeded BlockTone face attribute (paint_blocks) shades each stone differently. Dressed stone is paler and smoother than ashlar; mortar is a flat grey-beige. The baked normal map is chained under the bump (wire_normal) rather than replacing it.
Fixed seed 23; no unseeded randomness. Every measured value above is byte-identical on 4.5.11, 5.1.2 and 5.2.1 except LOD2, where DECIMATE COLLAPSE produces 244 triangles on 5.2 and 250 on 4.5 and 5.1. That is why the LOD gate is a ratio band (0.10–0.35, measured 0.2140 and 0.2193) and not an exact count.
Each breaks one pipeline stage so a named budget fails. All eight were run on 4.5.11, 5.1.2 and 5.2.1 and produced the same exit code on all three.
| Flag | Breaks | Exit |
|---|---|---|
--skip-decimate | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 |
--stray-vert | adds one loose vertex inside the opening, so hygiene catches it rather than the bounding box | 15 |
--lift-z | lifts the whole mesh 50 mm off the floor | 16 |
--float-pier | floats one pier 12 mm; the other still grounds the AABB, so only the named-support budget sees it | 16 |
--sink-keystone | sinks the keystone to a quarter of its projection (8.75 mm) — the imposts still set the Y envelope, so the bounding box is unchanged | 17 |
--wide-mortar | triples the joint angle, opening every joint to 29.1 mm | 18 |
--off-circle | wanders the intrados radius ±18 mm while keeping angles, joints and envelope | 19 |
--short-mortar | stops every mortar joint 1 mm shy of both stones; each of the 18 touches none, mortar-contact budget | 18 |
Three of these needed the model changed, not the budget:
--sink-keystone originally removed the projection outright, which shrank the Y bounding box by 70 mm and tripped the AABB gate first. The imposts were added so the envelope no longer depends on the keystone — and an impost course is correct masonry the arch was missing anyway.--off-circle began as --flat-arch, laying the voussoirs as a lintel. That is 0.62 m shorter and fails on the bounding box, proving nothing about the circle fit. Wandering the radius inside the same envelope is the honest version.--float-pier tripped the z-fight budget rather than the support budget, because the impost was pinned to an absolute height while the courses under it rose into it. The impost now rides on its own pier.bmesh.ops.bevel gives every face it creates material_index 0, so chamfering nineteen blocks left 30 faces on the dressed slot and moved 464 to ashlar. The slot count and the distinct-material check both still passed. Only the per-material face floor caught it, which is exactly the class showcase/README.md warns about. Materials are now re-stamped after the bevel, per block, by nearest recorded centroid.N_COURSE beds rather than N_COURSE - 1.find_nearest is unsigned. A voussoir seated inside the pier head reported a 5.4 mm gap where there was none, because the distance to the host's skin is positive from inside too. Joint gaps now test BVH overlap first and return zero when two blocks interpenetrate.File-local and sequential. 9 is a valid check code; there is no rule against it. 1 is the FATAL wrapper — a crash, never a named check.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Mesh did not build, or has no UV layer |
| 4 | Base triangle count outside band |
| 5 | Material slots, or a material's face floor |
| 6 | UVs outside 0..1 |
| 7 | UV AABB overlap above tolerance |
| 8 | Outer AABB off declared size |
| 9 | LOD1 or LOD2 ratio outside band (--skip-decimate) |
| 10 | Framing gate (examples/gallery_framing.py, render path only) |
| 11 | Collider triangles above ceiling |
| 12 | Normal bake failed or produced no image data |
| 13 | glTF export missing or empty |
| 14 | --output produced no file |
| 15 | Mesh hygiene (--stray-vert) |
| 16 | Grounded zmin, or a pier base floating (--lift-z, --float-pier) |
| 17 | Keystone projection or springing joint (--sink-keystone) |
| 18 | Mortar joint outside band (--wide-mortar), or a mortar shell not seated in exactly two stones (--short-mortar) |
| 19 | Intrados circle fit, or clear opening (--off-circle) |
# Budget check, no render. ~1.1 s on 4.5, ~1.2 s on 5.1, ~1.4 s on 5.2.
blender --background --python stone_archway.py --
# Falsifier: the intrados stops being a circle. Must exit 19.
blender --background --python stone_archway.py -- --off-circle
# Falsifier: every mortar joint opens to 29 mm. Must exit 18.
blender --background --python stone_archway.py -- --wide-mortar
# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
blender --background --python stone_archway.py -- --output arch.webp
Smoke runs the check-only path. It does not pass --output or any falsifier.
| Value | 4.5.11 | 5.1.2 | 5.2.1 |
|---|---|---|---|
| Base triangles | 1140 | 1140 | 1140 |
| LOD1 tris / ratio | 570 / 0.5000 | 570 / 0.5000 | 570 / 0.5000 |
| LOD2 tris / ratio | 250 / 0.2193 | 250 / 0.2193 | 244 / 0.2140 |
| Face counts (ashlar / dressed / mortar) | 364 / 182 / 108 | same | same |
| Outer AABB | 1.6440 × 0.5100 × 2.0568 | same | same |
| Collider tris | 168 | 168 | 168 |
| Intrados deviation | 0.00097 | 0.00097 | 0.00097 |
| Mortar joints | all 0.00970 | all 0.00970 | all 0.00970 |
| Mortar contact | 18 × 2 stones | same | same |
| Check wall-clock | ~1.11 s | ~1.18 s | ~1.37 s |
"""Game-ready stone archway — a showcase piece, not an example. Asserts budget conformance of a procedural masonry arch: two coursed piers, nine voussoirs turning a semicircle, and a proud keystone, carried through UVs, three materials (ashlar, dressed stone, mortar), a high-to-low normal bake, an LOD chain, a convex collider, and a Unity glTF export. The budget that matters here is the one an arch can fail invisibly: the voussoir intrados vertices must lie on a circle of the declared radius, recomputed from vertex positions rather than from the angles the generator used. A row of wedges that never turned is still a row of wedges; only the circle fit knows the difference. Budgets are declared below and recomputed from the generated result. 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, ``--float-pier`` the named pier supports, ``--sink-keystone`` the keystone joint, ``--wide-mortar`` the mortar-joint band, ``--off-circle`` the intrados circle fit, ``--short-mortar`` the mortar-contact budget. Fixed seed 23 for course and block weathering. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a ratio band, not an exact count. blender --background --python stone_archway.py -- blender --background --python stone_archway.py -- --off-circle blender --background --python stone_archway.py -- --output arch.png """ import argparse import math import os import random import sys import tempfile import traceback import bmesh import bpy 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) ) sys.path.insert(0, os.path.join(_REPO, "examples")) sys.dont_write_bytecode = True import gallery_framing # noqa: E402 # A 1.2 m clear opening under a semicircular head: a gate arch, about # 1.6 m across the piers and 2.0 m to the crown. R_IN = 0.60 T_V = 0.20 R_OUT = R_IN + T_V WALL_Y = 0.40 H_SPRING = 1.20 N_VOUSSOIR = 9 KEY_INDEX = N_VOUSSOIR // 2 # The keystone stands proud of the wall face and rises past the extrados. KEY_PROUD = 0.035 KEY_RISE = 0.080 # The keystone reads by standing proud and rising past the extrados, not # by being angularly wider: widening it eats its neighbours' mortar joints. KEY_WIDEN = 0.0 # Voussoirs bite down into the pier head so the springing joint is an # overlap, not two faces sharing the z = H_SPRING plane. SPRING_BITE = 0.018 # Imposts: the projecting string-course at the springing line. They set # the Y envelope, which is what lets the keystone falsifier change the # keystone without changing the bounding box. IMPOST_PROUD = 0.055 IMPOST_H = 0.075 IMPOST_OUT = 0.022 # Radial mortar joints, as an angle so the joint stays radial. MORTAR_ANG = 0.016 N_COURSE = 5 COURSE_MORTAR = 0.007 WEATHER = 0.18 ARCH_SEED = 23 # Worn arrises. Also what lifts the block count out of programmer-art # territory: nineteen unbevelled boxes are 228 triangles. CHAMFER = 0.006 STACK_H = H_SPRING + R_OUT + KEY_RISE BBOX_TOL = 0.020 OUTER_SIZE = (1.644, 0.510, 2.057) SPAN_TOL = 0.015 OPENING_W = 2.0 * R_IN BASE_TRIS_MIN = 950 BASE_TRIS_MAX = 1450 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 LOD2_RATIO_MAX = 0.35 LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 3 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 COLLIDER_TRIS_MAX = 260 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 DRESSED_FACES_MIN = 100 ASHLAR_FACES_MIN = 280 ZMIN_EPS = 1e-4 DOUBLES_EPS = 1e-5 AREA_EPS = 1e-10 ZFIGHT_EPS = 1e-4 ZFIGHT_COS = 0.999 LIFT_Z = 0.05 PIER_Z_MAX = 1e-3 PIERS_MIN = 2 # Intrados circle fit: every inner-arc vertex within this of R_IN. ARC_TOL = 0.004 ARC_SPAN_MIN = math.radians(168.0) # Mortar joint band, measured surface-to-surface per adjacent pair. MORTAR_MIN = 0.006 MORTAR_MAX = 0.017 KEY_PROUD_MIN = 0.020 SPRING_GAP_MAX = 1e-4 FLOAT_PIER_LIFT = 0.012 ASHLAR_IDX = 0 DRESSED_IDX = 1 MORTAR_IDX = 2 # Mortar fills every joint. Each bed or wedge is built from the two blocks # it sits between: recessed behind their faces (deeper than the chamfer, so # it reads as a raked joint rather than a filled one) and biting into both. # Without it every course floated on 7 mm of air and daylight showed # through each joint. MORTAR_RECESS = CHAMFER + 0.003 MORTAR_BITE = 0.0015 SHORT_MORTAR_CLEAR = 0.001 N_MORTAR = 2 * N_COURSE + (N_VOUSSOIR - 1) MORTAR_FACES_MIN = 100 BLOCK_TONE_JITTER = 0.25 def eevee_engine_id(): """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" def fail(msg, code): print(f"FAIL[{code}]: {msg}", file=sys.stderr) return code def triangle_count(mesh): mesh.calc_loop_triangles() return len(mesh.loop_triangles) def evaluated_triangle_count(obj): deps = bpy.context.evaluated_depsgraph_get() ev = obj.evaluated_get(deps) mesh = ev.to_mesh() try: mesh.calc_loop_triangles() return len(mesh.loop_triangles) finally: ev.to_mesh_clear() def add_box(bm, loc, scale, mat_idx): geo = bmesh.ops.create_cube(bm, size=1.0) verts = geo["verts"] for v in verts: v.co = Vector( ( v.co.x * scale[0] + loc[0], v.co.y * scale[1] + loc[1], v.co.z * scale[2] + loc[2], ) ) for f in {f for v in verts for f in v.link_faces}: f.material_index = mat_idx return verts, (Vector(loc), mat_idx) def add_wedge(bm, cz, a0, a1, r0, r1, hy, mat_idx): """One voussoir: a radial wedge in XZ, extruded across the wall in Y. Eight vertices, six quads, closed. The radial end faces are what the mortar-joint budget measures, so they stay flat and parallel to the neighbour's rather than being bevelled away. """ prof = ((a0, r0), (a1, r0), (a1, r1), (a0, r1)) rows = [] for y in (-hy, hy): rows.append( [ bm.verts.new((r * math.cos(a), y, cz + r * math.sin(a))) for a, r in prof ] ) faces = [bm.faces.new(rows[0]), bm.faces.new(tuple(reversed(rows[1])))] for k in range(4): kn = (k + 1) % 4 faces.append( bm.faces.new((rows[0][k], rows[0][kn], rows[1][kn], rows[1][k])) ) for f in faces: f.material_index = mat_idx vs = [v for row in rows for v in row] c = sum((v.co for v in vs), Vector((0.0, 0.0, 0.0))) / len(vs) return vs, (c, mat_idx) def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) n = len(faces) cols = max(1, math.ceil(math.sqrt(n))) rows = max(1, math.ceil(n / cols)) cell_w = 1.0 / cols cell_h = 1.0 / rows pad_u = margin * cell_w * 0.5 pad_v = margin * cell_h * 0.5 usable_w = cell_w - 2.0 * pad_u usable_h = cell_h - 2.0 * pad_v for i, face in enumerate(faces): col = i % cols row = i // cols nrm = face.normal ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) coords = [] for loop in face.loops: co = loop.vert.co if az >= ax and az >= ay: coords.append((co.x, co.y)) elif ax >= ay: coords.append((co.y, co.z)) else: coords.append((co.x, co.z)) xs = [c[0] for c in coords] ys = [c[1] for c in coords] minx, maxx = min(xs), max(xs) miny, maxy = min(ys), max(ys) dx = max(maxx - minx, 1e-8) dy = max(maxy - miny, 1e-8) ou = col * cell_w + pad_u ov = row * cell_h + pad_v for loop, (x, y) in zip(face.loops, coords): loop[uv].uv = ( ou + (x - minx) / dx * usable_w, ov + (y - miny) / dy * usable_h, ) def assign_materials_by_block(bm, blocks): """Re-stamp material indices after the chamfer pass. ``bmesh.ops.bevel`` gives every face it creates ``material_index`` 0, so chamfering nineteen blocks repainted 464 of 494 faces with the first slot and left 30 on the second. The slot count and the distinct material check both still passed; only a per-material face floor catches it. Each block is its own shell, so re-deriving the material from the nearest recorded block centroid restores the assignment without depending on face or vertex ordering. """ seen = set() for v in bm.verts: if v in seen: continue stack = [v] seen.add(v) comp = [] while stack: cur = stack.pop() comp.append(cur) for e in cur.link_edges: other = e.other_vert(cur) if other not in seen: seen.add(other) stack.append(other) centre = sum((x.co for x in comp), Vector((0.0, 0.0, 0.0))) / len(comp) _c, mat = min(blocks, key=lambda b: (b[0] - centre).length_squared) for f in {f for x in comp for f in x.link_faces}: f.material_index = mat def build_pier(bm, sign, rng, float_pier=False): """One coursed pier, base course on the floor, head at H_SPRING.""" raw = [1.0 + rng.uniform(-WEATHER, WEATHER) for _ in range(N_COURSE)] total = sum(raw) # N_COURSE beds, not N_COURSE-1: the impost needs one under it too, # or its underside lands exactly on the top course and the two share # a face centre, which is a z-fight. usable = H_SPRING - IMPOST_H - COURSE_MORTAR * N_COURSE heights = [usable * r / total for r in raw] verts = [] blocks = [] # (width, depth, zmin, zmax) per block, bottom up, impost last: what # the mortar beds between them are sized from. stack = [] z = FLOAT_PIER_LIFT if (float_pier and sign < 0) else 0.0 for i, h in enumerate(heights): # Courses weather back a little as they rise; the jitter also keeps # two neighbouring courses from presenting identical faces. inset = rng.uniform(0.0, 0.010) depth = WALL_Y - rng.uniform(0.0, 0.014) mat = DRESSED_IDX if i in (0, N_COURSE - 1) else ASHLAR_IDX vs, block = add_box( bm, (sign * (R_IN + T_V / 2.0), 0.0, z + h / 2.0), (T_V - inset, depth, h), mat, ) verts.extend(vs) blocks.append(block) stack.append((T_V - inset, depth, z, z + h)) z += h + COURSE_MORTAR # The impost rides on the courses rather than sitting at an absolute # height: pinned to H_SPRING it stays put while --float-pier lifts the # courses into it, and the two then share a face plane. vs, block = add_box( bm, (sign * (R_IN + T_V / 2.0), 0.0, z + IMPOST_H / 2.0), (T_V + 2.0 * IMPOST_OUT, WALL_Y + 2.0 * IMPOST_PROUD, IMPOST_H), DRESSED_IDX, ) verts.extend(vs) blocks.append(block) stack.append((T_V + 2.0 * IMPOST_OUT, WALL_Y + 2.0 * IMPOST_PROUD, z, z + IMPOST_H)) return verts, blocks, stack def build_arch(bm, rng, off_circle=False, sink_keystone=False, wide_mortar=False): """Nine voussoirs turning 0..pi, keystone at the crown.""" gap = MORTAR_ANG * (3.0 if wide_mortar else 1.0) step = math.pi / N_VOUSSOIR cz = H_SPRING - SPRING_BITE verts = [] blocks = [] wedges = [] for k in range(N_VOUSSOIR): is_key = k == KEY_INDEX widen = KEY_WIDEN if is_key else 0.0 a0 = k * step + gap / 2.0 - widen a1 = (k + 1) * step - gap / 2.0 + widen r1 = R_OUT + (KEY_RISE if is_key else 0.0) hy = WALL_Y / 2.0 if is_key: # Sunk, not removed: the imposts still set the Y envelope, so # the bounding box is unchanged and the keystone budget is the # only thing that can see this. hy += KEY_PROUD * (0.25 if sink_keystone else 1.0) mat = DRESSED_IDX if is_key else ASHLAR_IDX r0 = R_IN if off_circle: # Same angles, same joints, same envelope — only the intrados # radius wanders. This is the failure the circle fit exists # for: nothing else in the piece can see it. r0 = R_IN + (0.018 if k % 2 else -0.018) # Weathering rides on the extrados only; the intrados is the face # the circle-fit budget measures and is left true. r1 += rng.uniform(0.0, 0.012) vs, block = add_wedge(bm, cz, a0, a1, r0, r1, hy, mat) verts.extend(vs) blocks.append(block) wedges.append((a0, a1, r0, r1, hy)) return verts, blocks, wedges def add_mortar(bm, sign, stack, wedges, short=False): """Beds between pier courses and wedges between voussoirs. Every dimension comes from the two blocks the joint sits between, so a course that weathers back or a voussoir that rises takes its mortar with it. ``short`` stops each joint SHORT_MORTAR_CLEAR shy of both blocks — the falsifier for the mortar-contact budget. """ bite = -SHORT_MORTAR_CLEAR if short else MORTAR_BITE if stack is not None: x = sign * (R_IN + T_V / 2.0) for (w0, d0, _z0, top), (w1, d1, bot, _z1) in zip(stack, stack[1:]): h = (bot - top) + 2.0 * bite add_box( bm, (x, 0.0, 0.5 * (top + bot)), (min(w0, w1) - 2.0 * MORTAR_RECESS, min(d0, d1) - 2.0 * MORTAR_RECESS, h), MORTAR_IDX, ) if wedges is not None: cz = H_SPRING - SPRING_BITE for (_a0, a1, r0a, r1a, hya), (b0, _b1, r0b, r1b, hyb) in zip(wedges, wedges[1:]): ri = max(r0a, r0b) + MORTAR_RECESS # Angular bite taken at the inner radius, so it is never less # than MORTAR_BITE anywhere along the joint. da = bite / ri add_wedge( bm, cz, a1 - da, b0 + da, ri, min(r1a, r1b) - MORTAR_RECESS, min(hya, hyb) - MORTAR_RECESS, MORTAR_IDX, ) def build_arch_mesh( name, off_circle=False, sink_keystone=False, wide_mortar=False, float_pier=False, short_mortar=False, ): rng = random.Random(ARCH_SEED) bm = bmesh.new() try: blocks = [] stacks = [] for sign in (-1.0, 1.0): _v, bl, st = build_pier(bm, sign, rng, float_pier=float_pier) blocks.extend(bl) stacks.append((sign, st)) _v, bl, wedges = build_arch( bm, rng, off_circle=off_circle, sink_keystone=sink_keystone, wide_mortar=wide_mortar, ) blocks.extend(bl) edges = list(bm.edges) if edges: bmesh.ops.bevel( bm, geom=edges, offset=CHAMFER, segments=1, profile=0.5, affect="EDGES", clamp_overlap=True, ) assign_materials_by_block(bm, blocks) # Mortar after the chamfer: it sits recessed behind the stone and a # chamfer on a 10 mm bed would eat it. It also stays out of the # block re-stamp, which only knows stone. for sign, st in stacks: add_mortar(bm, sign, st, None, short=short_mortar) add_mortar(bm, 0.0, None, wedges, short=short_mortar) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: face.smooth = False me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() finally: bm.free() paint_blocks(me) obj = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(obj) return obj def paint_blocks(me): """A seeded tone per stone, as the ``BlockTone`` face attribute. Nineteen blocks out of one material are one block repeated; a quarried arch has every stone a shade apart. Mortar keeps the neutral 0.5. """ tone = [0.5] * len(me.polygons) mortar = mortar_verts(me) owner = {} rng = random.Random(ARCH_SEED * 17) for g in shells(me): t = 0.5 if g[0] in mortar else 0.5 + rng.uniform(-BLOCK_TONE_JITTER, BLOCK_TONE_JITTER) for i in g: owner[i] = t for p in me.polygons: tone[p.index] = owner[p.vertices[0]] attr = me.attributes.new("BlockTone", "FLOAT", "FACE") attr.data.foreach_set("value", tone) def _sock(sockets, identifier): """A Mix-node socket by identifier; its A/B/Result names repeat per type.""" return next(sk for sk in sockets if sk.identifier == identifier) def stone_material(name, light, dark, roughness, mottle, speck, bump): """Stone, not timber: isotropic mottling, fine speckle and pitting. The old noise-driven mix read as long brown streaks at hero scale, and the ashlar looked like wood grain. Everything here samples object space with isotropic noise, so nothing in the pattern has a direction. """ mat = bpy.data.materials.new(name) mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] coord = nt.nodes.new("ShaderNodeTexCoord") mot = nt.nodes.new("ShaderNodeTexNoise") mot.inputs["Scale"].default_value = mottle mot.inputs["Detail"].default_value = 4.0 mot.inputs["Roughness"].default_value = 0.55 nt.links.new(coord.outputs["Object"], mot.inputs["Vector"]) ramp = nt.nodes.new("ShaderNodeValToRGB") ramp.color_ramp.elements[0].position = 0.32 ramp.color_ramp.elements[0].color = dark ramp.color_ramp.elements[1].position = 0.70 ramp.color_ramp.elements[1].color = light nt.links.new(mot.outputs["Fac"], ramp.inputs["Fac"]) spk = nt.nodes.new("ShaderNodeTexNoise") spk.inputs["Scale"].default_value = speck spk.inputs["Detail"].default_value = 2.0 nt.links.new(coord.outputs["Object"], spk.inputs["Vector"]) gain = nt.nodes.new("ShaderNodeMapRange") gain.inputs["To Min"].default_value = 0.82 gain.inputs["To Max"].default_value = 1.12 nt.links.new(spk.outputs["Fac"], gain.inputs["Value"]) mix = nt.nodes.new("ShaderNodeMix") mix.data_type = "RGBA" mix.blend_type = "MULTIPLY" _sock(mix.inputs, "Factor_Float").default_value = 1.0 nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color")) nt.links.new(gain.outputs["Result"], _sock(mix.inputs, "B_Color")) # Per-stone shade from the BlockTone face attribute (paint_blocks). tone = nt.nodes.new("ShaderNodeAttribute") tone.attribute_name = "BlockTone" shade = nt.nodes.new("ShaderNodeMath") shade.operation = "MULTIPLY_ADD" shade.inputs[1].default_value = 0.8 shade.inputs[2].default_value = 0.6 nt.links.new(tone.outputs["Fac"], shade.inputs[0]) mix2 = nt.nodes.new("ShaderNodeMix") mix2.data_type = "RGBA" mix2.blend_type = "MULTIPLY" _sock(mix2.inputs, "Factor_Float").default_value = 1.0 nt.links.new(_sock(mix.outputs, "Result_Color"), _sock(mix2.inputs, "A_Color")) nt.links.new(shade.outputs["Value"], _sock(mix2.inputs, "B_Color")) nt.links.new(_sock(mix2.outputs, "Result_Color"), bsdf.inputs["Base Color"]) rough = nt.nodes.new("ShaderNodeMapRange") rough.inputs["To Min"].default_value = roughness - 0.06 rough.inputs["To Max"].default_value = min(1.0, roughness + 0.08) nt.links.new(spk.outputs["Fac"], rough.inputs["Value"]) nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) if bump > 0.0: bmp = nt.nodes.new("ShaderNodeBump") bmp.inputs["Strength"].default_value = bump bmp.inputs["Distance"].default_value = 0.002 nt.links.new(spk.outputs["Fac"], bmp.inputs["Height"]) nt.links.new(bmp.outputs["Normal"], bsdf.inputs["Normal"]) return mat def arch_materials(): """(ashlar, dressed, mortar): shared by the check, the render and inspection.""" ashlar = stone_material( "ArchAshlar", (0.40, 0.355, 0.285, 1.0), (0.235, 0.205, 0.160, 1.0), 0.86, mottle=5.0, speck=140.0, bump=0.30, ) dressed = stone_material( "ArchDressed", (0.56, 0.50, 0.405, 1.0), (0.40, 0.355, 0.285, 1.0), 0.66, mottle=3.0, speck=220.0, bump=0.12, ) mortar = stone_material( "ArchMortar", (0.50, 0.485, 0.450, 1.0), (0.40, 0.385, 0.355, 1.0), 0.95, mottle=12.0, speck=300.0, bump=0.0, ) return ashlar, dressed, mortar def assign_slots(obj, ashlar, dressed, mortar): mats = obj.data.materials for i, mat in enumerate((ashlar, dressed, mortar)): if i < len(mats): mats[i] = mat else: mats.append(mat) def world_bbox(obj): corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] xs = [c.x for c in corners] ys = [c.y for c in corners] zs = [c.z for c in corners] return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: return 0.0, 0.0, 1.0, 1.0, 0.0, 0 data = uv.data us = [loop.uv[0] for loop in data] vs = [loop.uv[1] for loop in data] aabbs = [] for poly in mesh.polygons: pu = [data[i].uv[0] for i in poly.loop_indices] pv = [data[i].uv[1] for i in poly.loop_indices] aabbs.append((min(pu), min(pv), max(pu), max(pv))) span = max( 1e-6, max((a[2] - a[0]) for a in aabbs), max((a[3] - a[1]) for a in aabbs), ) buckets = {} for i, a in enumerate(aabbs): for c in range(int(a[0] // span), int(a[2] // span) + 1): for r in range(int(a[1] // span), int(a[3] // span) + 1): buckets.setdefault((c, r), []).append(i) overlap = 0.0 seen = set() for members in buckets.values(): for ii in range(len(members)): for jj in range(ii + 1, len(members)): i, j = members[ii], members[jj] key = (i, j) if i < j else (j, i) if key in seen: continue seen.add(key) a, b = aabbs[i], aabbs[j] overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max( 0.0, min(a[3], b[3]) - max(a[1], b[1]) ) return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) def face_area(me, poly): idxs = poly.vertices if len(idxs) < 3: return 0.0 v0 = me.vertices[idxs[0]].co area = 0.0 for i in range(1, len(idxs) - 1): a = me.vertices[idxs[i]].co b = me.vertices[idxs[i + 1]].co area += (a - v0).cross(b - v0).length * 0.5 return area def hygiene_audit(me): nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) bm = bmesh.new() try: bm.from_mesh(me) 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, } def zfight_pairs(me): """Coplanar, near-coincident face pairs that share no vertex.""" data = [ (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) for p in me.polygons ] cell = ZFIGHT_EPS buckets = {} for i, (c, _n, _v) in enumerate(data): key = ( int(math.floor(c.x / cell)), int(math.floor(c.y / cell)), int(math.floor(c.z / cell)), ) buckets.setdefault(key, []).append(i) eps2 = ZFIGHT_EPS * ZFIGHT_EPS count = 0 checked = set() for (kx, ky, kz), members in buckets.items(): near = [] for dx in (-1, 0, 1): for dy in (-1, 0, 1): for dz in (-1, 0, 1): near.extend(buckets.get((kx + dx, ky + dy, kz + dz), ())) for i in members: ci, ni, vi = data[i] for j in near: if j == i: continue pair = (i, j) if i < j else (j, i) if pair in checked: continue checked.add(pair) 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: cur = stack.pop() group.append(cur) for nxt in neighbors[cur]: if not seen[nxt]: seen[nxt] = True stack.append(nxt) groups.append(group) return groups def shell_tree(me, group): """A BVH for one shell, built once so pair gaps stay cheap.""" bm = bmesh.new() try: bm.from_mesh(me) member = set(group) drop = [f for f in bm.faces if not all(v.index in member for v in f.verts)] if drop: bmesh.ops.delete(bm, geom=drop, context="FACES") if not bm.faces: return None return BVHTree.FromBMesh(bm) finally: bm.free() def pair_gap(me, tree_a, group_b, tree_b=None): """Surface gap from shell B to shell A; 0 when the two interpenetrate. find_nearest returns an unsigned distance, so a block seated *inside* its host reports the distance to the host's skin rather than zero. The springing joint is an overlap by design, so without the overlap test first this reads a 5 mm gap where there is none. """ if tree_a is None: return 99.0 if tree_b is not None and tree_a.overlap(tree_b): return 0.0 best = 99.0 for i in group_b: hit = tree_a.find_nearest(me.vertices[i].co) if hit[0] is None: continue best = min(best, hit[3]) return best def mortar_verts(me): """Vertices of mortar faces. Mortar is its own shells; stone budgets skip it.""" return { i for p in me.polygons if p.material_index == MORTAR_IDX for i in p.vertices } def classify(me): """Name the stone shells: pier courses by side, voussoirs by crown angle. Mortar shells are skipped. Left in, a pier bed classifies as a course and a voussoir wedge as a voussoir, and every stone budget after this measures the wrong neighbours. """ out = {"left": [], "right": [], "voussoirs": []} mortar = mortar_verts(me) for g in shells(me): if g[0] in mortar: continue pts = [me.vertices[i].co for i in g] zmin = min(p.z for p in pts) zmax = max(p.z for p in pts) xc = sum(p.x for p in pts) / len(pts) rec = {"g": g, "zmin": zmin, "zmax": zmax, "xc": xc, "pts": pts} if zmax <= H_SPRING + 1e-6: (out["left"] if xc < 0 else out["right"]).append(rec) else: out["voussoirs"].append(rec) out["left"].sort(key=lambda r: r["zmin"]) out["right"].sort(key=lambda r: r["zmin"]) # Around the arch, not left to right: atan2 about the springing centre # orders the voussoirs even when a falsifier has flattened them. out["voussoirs"].sort( key=lambda r: math.atan2( max(1e-9, sum(p.z for p in r["pts"]) / len(r["pts"]) - (H_SPRING - SPRING_BITE)), sum(p.x for p in r["pts"]) / len(r["pts"]), ), reverse=True, ) return out def arch_audit(me): """Circle fit, mortar band, keystone proudness, pier supports.""" parts = classify(me) cz = H_SPRING - SPRING_BITE # Intrados circle fit, measured on the vertices of faces that actually # face the springing centre. Selecting by radius alone would sweep in # the chamfer vertices sitting one bevel-width out on each radial face, # and report a 6 mm error on a true arch. member = {} for rec in parts["voussoirs"]: for i in rec["g"]: member[i] = True radii = [] angles = [] for poly in me.polygons: if not all(i in member for i in poly.vertices): continue c = poly.center rc = math.hypot(c.x, c.z - cz) if rc > R_IN + T_V * 0.5: continue inward = Vector((-c.x, 0.0, -(c.z - cz))) if inward.length < 1e-9: continue inward.normalize() n = poly.normal if abs(n.y) > 0.2: continue if Vector((n.x, 0.0, n.z)).normalized().dot(inward) < 0.9: continue for i in poly.vertices: p = me.vertices[i].co radii.append(math.hypot(p.x, p.z - cz)) angles.append(math.atan2(p.z - cz, p.x)) arc_dev = max((abs(r - R_IN) for r in radii), default=99.0) arc_span = (max(angles) - min(angles)) if angles else 0.0 # Mortar joints, surface to surface, per adjacent pair around the arch. vs = parts["voussoirs"] trees = [shell_tree(me, rec["g"]) for rec in vs] gaps = [] for i in range(len(vs) - 1): gaps.append( min( pair_gap(me, trees[i], vs[i + 1]["g"], trees[i + 1]), pair_gap(me, trees[i + 1], vs[i]["g"], trees[i]), ) ) gap_min = min(gaps) if gaps else 99.0 gap_max = max(gaps) if gaps else 99.0 # Keystone: the voussoir that stands proud of the wall face. wall_half = WALL_Y / 2.0 prouds = [ max(abs(p.y) for p in rec["pts"]) - wall_half for rec in vs ] key_proud = max(prouds) if prouds else 0.0 # Springing joints must overlap the pier head, not rest on it. spring_gap = 0.0 for side in ("left", "right"): if not parts[side] or not vs: spring_gap = 99.0 continue head = parts[side][-1] tree = shell_tree(me, head["g"]) idx = min( range(len(vs)), key=lambda k: abs( sum(p.x for p in vs[k]["pts"]) / len(vs[k]["pts"]) - head["xc"] ), ) spring_gap = max( spring_gap, pair_gap(me, tree, vs[idx]["g"], trees[idx]) ) piers = 0 pier_z = 99.0 for side in ("left", "right"): if parts[side]: piers += 1 pier_z = min(pier_z, 99.0) pier_worst = max( (parts[s][0]["zmin"] for s in ("left", "right") if parts[s]), default=99.0 ) return { "n_vous": len(vs), "n_left": len(parts["left"]), "n_right": len(parts["right"]), "arc_dev": arc_dev, "arc_span": arc_span, "gap_min": gap_min, "gap_max": gap_max, "key_proud": key_proud, "spring_gap": spring_gap, "piers": piers, "pier_worst": pier_worst, "gaps": [round(g, 5) for g in gaps], } def mortar_audit(me): """Every mortar shell must be seated in exactly two stones. A joint that touches one stone is a bed left hanging; one that touches none is the air gap the mortar exists to fill. Overlap is the metric: each bed bites MORTAR_BITE into both blocks by construction, so the BVH trees must intersect, and a bed that stops short of a block does not. """ mortar = mortar_verts(me) stone_trees, mortar_trees = [], [] for g in shells(me): if len(g) < 4: continue (mortar_trees if g[0] in mortar else stone_trees).append(shell_tree(me, g)) seats = [] for mt in mortar_trees: seats.append(sum(1 for st in stone_trees if st is not None and mt.overlap(st))) return { "n": len(mortar_trees), "seat_min": min(seats, default=0), "seat_max": max(seats, default=0), } def opening_audit(me): """Clear opening width at mid-pier height, measured from vertices.""" # Band stops clear of the impost, which projects inboard of the pier # face and is not part of the clear opening. zc = H_SPRING * 0.5 half = H_SPRING * 0.40 xs_left = [ v.co.x for v in me.vertices if v.co.x < 0 and abs(v.co.z - zc) < half ] xs_right = [ v.co.x for v in me.vertices if v.co.x > 0 and abs(v.co.z - zc) < half ] if not xs_left or not xs_right: return 0.0 return min(xs_right) - max(xs_left) def add_stray_vert(me): bm = bmesh.new() try: bm.from_mesh(me) bm.verts.new((0.0, 0.0, H_SPRING * 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) if not skip_decimate and 0.0 < ratio < 1.0: mod = lod.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" mod.ratio = ratio return lod def convex_hull_collider(obj, name): mesh = bpy.data.meshes.new(name) bm = bmesh.new() try: bm.from_mesh(obj.data) result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) for key in ("geom_interior", "geom_unused"): geom = result.get(key) or [] if geom: bmesh.ops.delete(bm, geom=geom, context="VERTS") bm.to_mesh(mesh) mesh.update() finally: bm.free() collider = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(collider) collider.matrix_world = obj.matrix_world.copy() return collider def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None img = bpy.data.images.new("ArchNrm", size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" nodes = target_mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") tex.image = img nodes.active = tex tex.select = True obj.active_material_index = ASHLAR_IDX return img, tex def bake_normal(high, low): scene = bpy.context.scene scene.render.engine = "CYCLES" scene.cycles.device = "CPU" scene.cycles.samples = 1 scene.cycles.use_denoising = False 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 return bpy.ops.object.bake( type="NORMAL", use_selected_to_active=True, cage_extrusion=CAGE_EXTRUSION, use_cage=False, normal_space="TANGENT", margin=4, margin_type="ADJACENT_FACES", use_clear=True, target="IMAGE_TEXTURES", ) def export_unity(path, objects): 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] bpy.ops.export_scene.gltf( filepath=path, use_selection=True, export_yup=True, export_apply=True, export_draco_mesh_compression_enable=False, export_animations=False, ) def check( skip_decimate, lift_z=False, stray_vert=False, float_pier=False, sink_keystone=False, wide_mortar=False, off_circle=False, short_mortar=False, ): bpy.ops.wm.read_factory_settings(use_empty=True) flags = dict( off_circle=off_circle, sink_keystone=sink_keystone, wide_mortar=wide_mortar, float_pier=float_pier, short_mortar=short_mortar, ) nothing = (None,) * 5 low = build_arch_mesh("ArchLow", **flags) high = build_arch_mesh("ArchHigh", **flags) ashlar, dressed, mortar = arch_materials() assign_slots(low, ashlar, dressed, mortar) assign_slots(high, ashlar, dressed, mortar) 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("arch mesh did not build", 3),) + nothing base_tris = triangle_count(low.data) mats = [s for s in low.data.materials if s is not None] nmat = len(mats) distinct = len({id(s) for s in mats}) idx_counts = {} for poly in low.data.polygons: idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) bb = world_bbox(low) size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2] img, tex = setup_bake_image(low, ashlar) if img is None: return (fail("arch has no UV layer", 3),) + nothing bake_result = bake_normal(high, low) lod1 = make_lod(low, "ArchLOD1", LOD1_TARGET, skip_decimate) lod2 = make_lod(low, "ArchLOD2", LOD2_TARGET, skip_decimate) bpy.context.view_layer.update() lod1_tris = evaluated_triangle_count(lod1) lod2_tris = evaluated_triangle_count(lod2) r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 col_src = build_arch_mesh("ArchColSrc", **flags) collider = convex_hull_collider(col_src, "ArchCollider") bpy.data.objects.remove(col_src, do_unlink=True) col_tris = triangle_count(collider.data) export_path = os.path.join( tempfile.gettempdir(), f"bdt_stone_archway_{os.getpid()}.glb" ) if os.path.exists(export_path): os.remove(export_path) export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 # Blender points TMPDIR at its own temp preference, which on a portable # build is the working directory, so the export must not outlive this. if os.path.isfile(export_path): os.remove(export_path) hyg = hygiene_audit(low.data) zf = zfight_pairs(low.data) ar = arch_audit(low.data) opening = opening_audit(low.data) mo = mortar_audit(low.data) print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print(f"measured mat_index_counts={idx_counts}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" ) print( f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " f"overlap={overlap:.6f} nfaces={nfaces}" ) print( f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " f"outer={OUTER_SIZE} zmin={bb[2]:.5f} opening={opening:.4f}" ) print( f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) print( f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" ) print( f"measured arch vous={ar['n_vous']} courses={ar['n_left']}/{ar['n_right']} " f"arc_dev={ar['arc_dev']:.5f} arc_span={math.degrees(ar['arc_span']):.1f}deg " f"gap=({ar['gap_min']:.5f},{ar['gap_max']:.5f}) " f"key_proud={ar['key_proud']:.5f} spring_gap={ar['spring_gap']:.5f} " f"pier_worst={ar['pier_worst']:.5f} gaps={ar['gaps']}" ) print( f"measured mortar n={mo['n']} seats=[{mo['seat_min']}, {mo['seat_max']}]" ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return (fail( f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4 ),) + nothing if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT: return (fail( f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5 ),) + nothing if idx_counts.get(DRESSED_IDX, 0) < DRESSED_FACES_MIN: return (fail( f"dressed faces {idx_counts.get(DRESSED_IDX, 0)} < {DRESSED_FACES_MIN}", 5 ),) + nothing if idx_counts.get(ASHLAR_IDX, 0) < ASHLAR_FACES_MIN: return (fail( f"ashlar faces {idx_counts.get(ASHLAR_IDX, 0)} < {ASHLAR_FACES_MIN}", 5 ),) + nothing if idx_counts.get(MORTAR_IDX, 0) < MORTAR_FACES_MIN: return (fail( f"mortar faces {idx_counts.get(MORTAR_IDX, 0)} < {MORTAR_FACES_MIN}", 5 ),) + nothing if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: return (fail( f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6 ),) + nothing if overlap > UV_OVERLAP_MAX: return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing if ( abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL ): return (fail( f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8 ),) + nothing if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): return (fail( f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " "(--skip-decimate is the designed fail)", 9 ),) + nothing if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): return (fail( f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9 ),) + nothing if col_tris > COLLIDER_TRIS_MAX: return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing if bake_result != {"FINISHED"} or not img.has_data: return (fail( f"bake failed result={bake_result} has_data={img.has_data}", 12 ),) + nothing if export_size <= 0: return (fail("export file missing or empty", 13),) + nothing 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} " "(--stray-vert is the designed fail)", 15 ),) + nothing if abs(bb[2]) > ZMIN_EPS: return (fail( f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " "(--lift-z is the designed fail)", 16 ),) + nothing if ar["piers"] < PIERS_MIN or ar["pier_worst"] > PIER_Z_MAX: return (fail( f"pier supports {ar['piers']} worst base z={ar['pier_worst']:.5f} " "(--float-pier is the designed fail)", 16 ),) + nothing if ar["key_proud"] < KEY_PROUD_MIN or ar["spring_gap"] > SPRING_GAP_MAX: return (fail( f"keystone proud {ar['key_proud']:.5f} < {KEY_PROUD_MIN} or " f"springing gap {ar['spring_gap']:.5f} > {SPRING_GAP_MAX} " "(--sink-keystone is the designed fail)", 17 ),) + nothing if not (MORTAR_MIN <= ar["gap_min"] and ar["gap_max"] <= MORTAR_MAX): return (fail( f"mortar joints ({ar['gap_min']:.5f}, {ar['gap_max']:.5f}) outside " f"[{MORTAR_MIN}, {MORTAR_MAX}] (--wide-mortar is the designed fail)", 18 ),) + nothing if mo["n"] != N_MORTAR or mo["seat_min"] != 2 or mo["seat_max"] != 2: return (fail( f"mortar contact: {mo['n']} of {N_MORTAR} joints, stones seated per " f"joint [{mo['seat_min']}, {mo['seat_max']}], need exactly 2 " "(--short-mortar is the designed fail)", 18 ),) + nothing if ar["arc_dev"] > ARC_TOL or ar["arc_span"] < ARC_SPAN_MIN: return (fail( f"intrados off circle by {ar['arc_dev']:.5f} > {ARC_TOL} or spans " f"{math.degrees(ar['arc_span']):.1f} deg < " f"{math.degrees(ARC_SPAN_MIN):.1f} " "(--off-circle is the designed fail)", 19 ),) + nothing if abs(opening - OPENING_W) > SPAN_TOL: return (fail( f"clear opening {opening:.4f} off {OPENING_W}", 19 ),) + nothing return 0, low, high, ashlar, tex, collider def wire_normal(mat, tex): """Baked normal map into the BSDF, under the pitting bump if there is one.""" 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"]) bump = next((n for n in nt.nodes if n.bl_idname == "ShaderNodeBump"), None) if bump is not None: nt.links.new(nrm.outputs["Normal"], bump.inputs["Normal"]) else: nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) def render_still(low, ashlar, tex, path, engine): scene = bpy.context.scene wire_normal(ashlar, 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(-17.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=20.0) bm.to_mesh(floor_me) finally: bm.free() fmat = bpy.data.materials.new("Floor") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.location = (0.0, 9.0, 0.0) wall.rotation_euler = (math.radians(90), 0.0, 0.0) scene.collection.objects.link(wall) world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = ( 0.02, 0.021, 0.025, 1.0, ) scene.world = world def light(name, loc, energy, size, col, rot): ld = bpy.data.lights.new(name, "AREA") ld.energy = energy ld.size = size ld.color = col ob = bpy.data.objects.new(name, ld) ob.location = loc ob.rotation_euler = tuple(math.radians(a) for a in rot) scene.collection.objects.link(ob) light("Key", (-4.0, -4.6, 6.0), 640.0, 5.0, (1.0, 0.96, 0.90), (42, 0, -40)) light("Fill", (4.6, -3.2, 2.0), 108.0, 9.0, (0.75, 0.85, 1.00), (70, 0, 54)) light("Rim", (-2.2, 3.8, 3.4), 420.0, 3.5, (0.60, 0.78, 1.00), (-60, 0, 202)) light("Wedge", (1.35, 4.4, 2.35), 640.0, 6.0, (1.0, 0.70, 0.36), (-94, 0, 194)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (3.46, -5.18, 2.05) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) aim.location = (0.0, 0.0, STACK_H * 0.48) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" con.up_axis = "UP_Y" scene.camera = cam scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() if engine == "cycles": scene.cycles.samples = 32 scene.cycles.device = "CPU" else: try: scene.eevee.taa_render_samples = 64 except AttributeError: pass scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = ( "WEBP" if path.lower().endswith(".webp") else "PNG" ) if path.lower().endswith(".webp"): scene.render.image_settings.quality = 90 scene.render.filepath = path scene.view_settings.view_transform = "Standard" fcode = gallery_framing.check_framing( scene, cam, hero=[low], elements=[low], stage=[floor, wall], ) if fcode: return fcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): return fail("render produced no file", 14) return 0 def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] 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") p.add_argument("--lift-z", action="store_true") p.add_argument("--stray-vert", action="store_true") p.add_argument("--float-pier", action="store_true") p.add_argument("--sink-keystone", action="store_true") p.add_argument("--wide-mortar", action="store_true") p.add_argument("--off-circle", action="store_true") p.add_argument("--short-mortar", action="store_true") args = p.parse_args(argv) code, low, _high, ashlar, tex, _col = check( args.skip_decimate, lift_z=args.lift_z, stray_vert=args.stray_vert, float_pier=args.float_pier, sink_keystone=args.sink_keystone, wide_mortar=args.wide_mortar, off_circle=args.off_circle, short_mortar=args.short_mortar, ) if code: return code if args.output: rcode = render_still(low, ashlar, tex, os.path.abspath(args.output), args.engine) if rcode: return rcode print(f"rendered still {args.output}") print("stone-archway OK") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as e: traceback.print_exc() print(f"FATAL: {e}", file=sys.stderr) sys.exit(1)