From 45815227751f63d96526dae4ff314197de75e244 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:59:38 -0400 Subject: [PATCH 1/2] fix(showcase): lock iron-cauldron coaxial ferrules as hygiene budgets World-Z cups let leaning poles slice the lip. Coaxial shoes plus pole-axis bite keep the joint closed without a magic offset. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- docs/gallery/assets/iron-cauldron-hero.webp | Bin 10776 -> 24680 bytes docs/gallery/iron-cauldron/index.html | 1038 ++++++++++++++----- showcase/iron-cauldron/README.md | 98 +- showcase/iron-cauldron/iron_cauldron.py | 1008 +++++++++++++----- showcase/iron-cauldron/preview.webp | Bin 9732 -> 14484 bytes 5 files changed, 1628 insertions(+), 516 deletions(-) diff --git a/docs/gallery/assets/iron-cauldron-hero.webp b/docs/gallery/assets/iron-cauldron-hero.webp index c455fc3cb20ce7317703bb108877e2573dc1c84f..f981b302ee6687054b7d61bb0df408297f66ffde 100644 GIT binary patch literal 24680 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 iron-cauldron render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.013×0.986×0.827 m, pot–leg clearance 0.051 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 98 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 3664 tris, two materials with 224 wood and 1768 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.987×0.961×0.860 m, pot–leg clearance 0.046 m, 3 coaxial ferrules at zmin 0, ferrule bite −0.00185 m, hook-bail join 0, mesh hygiene all zero, LOD ratios in band, convex collider 250 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-legs 16; --float-hook 17; --pipe-ferrule 18.
blender --background --python showcase/iron-cauldron/iron_cauldron.py --
-

A showcase piece, not an example. Procedural hanging cauldron (bulged iron pot with an open mouth, bail, timber tripod) 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 hanging cauldron (lathed iron pot with a rolled rim, pipe bail through vertical ear rings, timber tripod tenoned into a turned crown, iron ferrule cups coaxial with the poles) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

Ferrules are lofted along the pole axis and shifted so the downhill rim sits at Z=0. A world-Z bucket lets a leaning pole slice the lip. ferrule_bite is measured in that same frame, and only samples wood inside the cup's own axis span — a pole hovering above the well cannot fake a seat.

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 | 3280–3520 | 3388 / 3388 / 3388 | | 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.2172 | | Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 450 wood, 1198 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.013, 0.986, 0.827) m ± 0.01 | (1.0128, 0.9865, 0.8271), zmin 0.002 | | Pot–leg clearance | ≥ 0.04 m (recomputed vs pole axes) | 0.0513 / 0.0513 / 0.0513 | | Collider tris | ≤ 200 | 98 | | Export | written, size > 0 | 245060 / 245060 / 245044 bytes |

-

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 8 tris leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis. The pot–leg clearance is recomputed from shell verts vs the three pole axes; clipping exits 3.

-

--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 | 2800–4200 | 3664 / 3664 / 3664 | | 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 2 distinct; ≥80 wood, ≥200 metal faces | 2 slots; 224 / 1768 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.987, 0.961, 0.860) m ± 0.015 | (0.9866, 0.9605, 0.8600), zmin 0 | | Collider tris | ≤ 280 | 250 | | Export | written, size > 0 | 287916 / 287916 / 287904 bytes |

+

Base triangles rose from 3388 to 3664 in the quality pass: coaxial ferrule cups and a dual-wall lathe with a rolled rim replaced the old cone-tripod / overlapping-torus construction.

+

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: 3 ferrules | each zmin ≤ 1e-3 | 3, cup_z 0.00000 | | Pot size | 0.355 m dia × 0.334 m high ± 0.04 | 0.3545 × 0.3340 | | Pot–leg clearance | ≥ 0.04 m | 0.0464 |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Hook-bail BVH gap | ≤ 0.010 m | 0.00000 | | Ferrule bite (pole r − inner wall, pole frame) | −0.006–0.000 m | −0.00185 |

+

DECIMATE COLLAPSE triangle counts are not identical across series — the gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a gated axis.

+

Falsifiers

+

Each violates one named budget. All seven 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-legs | named ferrule supports at Z=0 | 16 | | --float-hook | hook-bail gap | 17 | | --pipe-ferrule | ferrule bite (pole starts above the well) | 18 |

Run

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

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 1519 are the hygiene and joint-fit family.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer / pot clips tripod | | 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 ferrule floats | | 17 | Joint fit: hook-bail gap | | 18 | Seat: ferrule bite band | | 19 | Pot diameter or height off the stated real-world size |

Source

@@ -282,15 +296,24 @@

Source

showcase/iron-cauldron/iron_cauldron.py
View on GitHub → -
"""Game-ready iron cauldron — a showcase piece, not an example.
+      
"""Game-ready hanging iron cauldron — a showcase piece, not an example.
 
-Asserts budget conformance of a procedural hanging cauldron after composing
-shipped pipeline pieces: bmesh construction, UVs, two materials, high-to-low
-normal bake, LOD chain, convex collider, Unity glTF export.
+Asserts budget conformance of a procedural cauldron (lathed pot with a
+rolled rim, pipe bail through ear rings, timber tripod tenoned into a
+turned crown, iron ferrule cups) 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 an 8-sided cone tripod piercing a 10-gon cap, a bail
+of 12 boxes, a torus rim overlapping the lathe, and ferrules floating
+2 mm off the ground.
 
 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-legs`` named ferrule
+supports, ``--float-hook`` hook-bail joint-fit, ``--pipe-ferrule``
+ferrule-cup seat (pole starts above the well).
 
 No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
 are not byte-identical across Blender versions — the LOD gate is a
@@ -310,10 +333,8 @@ 

Source

import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree -# Showcase lives at repo-root/showcase/, not under examples/. The framing -# helper is the repo's only shared import and lives next to the examples; -# resolve the repo root so we do not move gallery_framing.py. _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) ) @@ -321,25 +342,48 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -POT_H = 0.32 -POT_Z0 = 0.14 -POT_THICK = 0.014 +# Camp cauldron ~40 cm across, hanging from an ~85 cm timber tripod +# whose feet land on a ~1.0 m circle. The pot's own size is gated +# separately from the outer AABB (the AABB is the tripod). +POT_H = 0.320 +POT_THICK = 0.012 N_AROUND = 32 N_RINGS = 12 -R_BOT = 0.050 -R_MID = 0.205 +R_BOT = 0.028 +R_MID = 0.185 R_TOP = 0.148 -APEX_Z = 0.82 -TRIPOD_R = 0.56 -BAIL_SEGS = 12 -BAIL_R = 0.009 -BBOX_TOL = 0.01 +POT_Z0 = 0.155 +APEX_Z = 0.860 +TRIPOD_R = 0.560 +POLE_SEGS = 16 +POLE_R_FOOT = 0.024 +POLE_R_TOP = 0.016 +FERRULE_H = 0.034 +FERRULE_T = 0.006 +FERRULE_SEGS = 16 +FERRULE_FLOOR = 0.004 +CROWN_H = 0.072 +CROWN_R = 0.046 +INSERT_R = 0.026 +BAIL_SEGS = 24 +BAIL_PIPE = 8 +BAIL_R = 0.007 +HOOK_R = 0.006 +EAR_MAJOR = 0.018 +EAR_MINOR = 0.0045 +POLE_OFFSET = math.radians(18.0) + +BBOX_TOL = 0.015 +OUTER_SIZE = (0.987, 0.961, 0.860) +BODY_DIA = 0.355 +BODY_DIA_TOL = 0.04 +BODY_H = 0.334 +BODY_H_TOL = 0.04 POT_LEG_CLEARANCE_MIN = 0.04 -# Fitted after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (1.013, 0.986, 0.827) -BASE_TRIS_MIN = 3280 -BASE_TRIS_MAX = 3520 +BASE_TRIS_MIN = 2800 +BASE_TRIS_MAX = 4200 +COLLIDER_TRIS_MAX = 280 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -349,22 +393,31 @@

Source

MATERIAL_COUNT = 2 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 200 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 -WOOD_FACES_MIN = 12 +METAL_FACES_MIN = 200 +WOOD_FACES_MIN = 80 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +FERRULE_Z_MAX = 1e-3 +HOOK_JOIN = 0.010 +FERRULE_BITE_MIN = -0.006 +FERRULE_BITE_MAX = 0.000 WOOD_IDX = 0 METAL_IDX = 1 def eevee_engine_id(): - return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + return "BLENDER_EEVEE_NEXT" if bpy.app.version >= (4, 2, 0) else "BLENDER_EEVEE" def fail(msg, code): - print(f"ERROR: {msg}", file=sys.stderr) + print(f"FAIL[{code}]: {msg}", file=sys.stderr) return code @@ -374,92 +427,154 @@

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() + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() try: - eval_mesh.calc_loop_triangles() - return len(eval_mesh.loop_triangles) + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) finally: - eval_obj.to_mesh_clear() + ev.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 _bridge_rings(bm, a, b, mat_idx): + if len(a) == 1 and len(b) > 1: + c = a[0] + for k in range(len(b)): + kn = (k + 1) % len(b) + face = bm.faces.new((c, b[k], b[kn])) + face.material_index = mat_idx + elif len(b) == 1 and len(a) > 1: + c = b[0] + for k in range(len(a)): + kn = (k + 1) % len(a) + face = bm.faces.new((c, a[kn], a[k])) + face.material_index = mat_idx + else: + segs = len(a) + for k in range(segs): + kn = (k + 1) % segs + face = bm.faces.new((a[k], a[kn], b[kn], b[k])) + face.material_index = mat_idx + + +def loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True, cyclic=False): + verts = [v for ring in rings for v in ring] + n = len(rings) + for i in range(n - 1): + _bridge_rings(bm, rings[i], rings[i + 1], mat_idx) + if cyclic and n > 2: + _bridge_rings(bm, rings[-1], rings[0], mat_idx) + if cap_start and len(rings[0]) > 1: + ring = rings[0] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[kn], ring[k])) + face.material_index = mat_idx + if cap_end and len(rings[-1]) > 1: + ring = rings[-1] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[k], ring[kn])) + face.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( - bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), - ) +def lathe_z(bm, profile, segs, mat_idx, z0=0.0): + rings = [] + for z, r in profile: + if r <= 1e-8: + rings.append([bm.verts.new((0.0, 0.0, z0 + z))]) + continue + ring = [] + for i in range(segs): + a = 2.0 * math.pi * i / segs + ring.append(bm.verts.new((r * math.cos(a), r * math.sin(a), z0 + z))) + rings.append(ring) + cap_start = len(rings[0]) > 1 + cap_end = len(rings[-1]) > 1 + return loft_rings(bm, rings, mat_idx, cap_start=cap_start, cap_end=cap_end) -def add_cone_between(bm, a, b, r1, r2, segments, mat_idx): +def lathe_axis(bm, a, b, profile, segs, mat_idx, cap_start=True, cap_end=True): a = Vector(a) b = Vector(b) delta = b - a length = delta.length if length < 1e-8: return [] - quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) - eul = quat.to_euler("XYZ") - return add_cone( - bm, (a + b) * 0.5, r1, r2, length, segments, mat_idx, - euler=(eul.x, eul.y, eul.z), - ) - - -def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( - bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + tangent = delta.normalized() + side = Vector((-tangent.y, tangent.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = tangent.cross(side).normalized() + rings = [] + for t, radius in profile: + p = a + tangent * (t * length) + if radius <= 1e-8: + rings.append([bm.verts.new(p)]) + continue + ring = [] + for i in range(segs): + ang = 2.0 * math.pi * i / segs + ring.append( + bm.verts.new( + p + + side * (radius * math.cos(ang)) + + up * (radius * math.sin(ang)) + ) + ) + rings.append(ring) + return loft_rings(bm, rings, mat_idx, cap_start=cap_start, cap_end=cap_end) + + +def add_cup_along(bm, origin, tangent, r_in, r_out, height, segs, mat_idx, floor=FERRULE_FLOOR): + """Closed iron shoe coaxial with the pole. + + `origin` is the intended ground station of the pole foot. The cup is + shifted along `tangent` so the downhill outer rim sits at Z=0 — + a world-Z bucket lets a leaning pole slice the lip. Returns the + shifted origin (axis point of the outer floor). + """ + t = Vector(tangent).normalized() + o = Vector(origin) + side = Vector((-t.y, t.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = t.cross(side).normalized() + lean = math.sqrt(max(0.0, 1.0 - t.z * t.z)) + o = o + t * ((r_out * lean) / max(t.z, 0.25)) + specs = ( + (0.0, r_out), + (height, r_out), + (height, r_in), + (floor, r_in), ) - verts = list(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_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) + rings = [] + for dist, r in specs: + p = o + t * dist + ring = [] + for i in range(segs): + a = 2.0 * math.pi * i / segs + ring.append( + bm.verts.new( + p + side * (r * math.cos(a)) + up * (r * math.sin(a)) + ) + ) + rings.append(ring) + loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True) + return o, t -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 12 - n_minor = 6 +def add_torus(bm, loc, major, minor, n_major, n_minor, mat_idx, euler=(0.0, 0.0, 0.0)): rings = [] for i in range(n_major): u = i * (2.0 * math.pi / n_major) @@ -471,6 +586,7 @@

Source

z = minor * math.sin(v) ring.append(bm.verts.new((x, y, z))) rings.append(ring) + verts = [v for ring in rings for v in ring] for i in range(n_major): i2 = (i + 1) % n_major for j in range(n_minor): @@ -479,7 +595,6 @@

Source

(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: @@ -487,6 +602,33 @@

Source

return verts +def add_pipe_curve(bm, points, radius, segs, mat_idx): + rings = [] + n = len(points) + for i, p in enumerate(points): + p = Vector(p) + if i < n - 1: + tangent = (Vector(points[i + 1]) - p).normalized() + else: + tangent = (p - Vector(points[i - 1])).normalized() + side = Vector((-tangent.y, tangent.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = tangent.cross(side).normalized() + ring = [] + for k in range(segs): + a = 2.0 * math.pi * k / segs + ring.append( + bm.verts.new( + p + side * (radius * math.cos(a)) + up * (radius * math.sin(a)) + ) + ) + rings.append(ring) + return loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True) + + def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) @@ -503,9 +645,7 @@

Source

col = i % cols row = i // cols nrm = face.normal - ax = abs(nrm.x) - ay = abs(nrm.y) - az = abs(nrm.z) + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) coords = [] for loop in face.loops: co = loop.vert.co @@ -532,36 +672,63 @@

Source

def pot_radius(z_local): t = max(0.0, min(1.0, z_local / POT_H)) - return (1.0 - t) ** 2 * R_BOT + 2.0 * t * (1.0 - t) * (R_MID * 1.35) + t ** 2 * R_TOP + return ( + (1.0 - t) ** 2 * R_BOT + + 2.0 * t * (1.0 - t) * (R_MID * 1.32) + + t ** 2 * R_TOP + ) + + +def pole_feet(): + feet = [] + for i in range(3): + ang = i * (2.0 * math.pi / 3.0) + POLE_OFFSET + feet.append(Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.0))) + return feet -def build_cauldron_mesh(name, bevel_offset, bevel_segments): +def build_cauldron_mesh( + name, + short_legs=False, + float_hook=False, + pipe_ferrule=False, +): bm = bmesh.new() wood_verts = [] - metal_faces = set() - pot_verts = [] try: - # Shared-vertex lathe: one continuous iron shell, not overlapping - # stave panels (those read as a gold grid under studio lighting). + # Dual-wall lathe plus a rolled rim. No overlapping torus, no + # extra bottom cylinder — those left a hole in the floor and a + # faceted lip sitting on the mouth. zs = [POT_H * i / (N_RINGS - 1) for i in range(N_RINGS)] - outers = [] - inners = [] + rim = ( + (POT_H + 0.004, R_TOP + 0.012), + (POT_H + 0.010, R_TOP + 0.018), + (POT_H + 0.014, R_TOP + 0.014), + (POT_H + 0.011, R_TOP + 0.006), + ) + outers, inners = [], [] for z_local in zs: z = POT_Z0 + z_local r = pot_radius(z_local) - ri = max(r - POT_THICK, 0.018) - oring = [] - iring = [] + ri = max(r - POT_THICK, 0.014) + oring, iring = [], [] + for i in range(N_AROUND): + a = 2.0 * math.pi * i / N_AROUND + oring.append(bm.verts.new((r * math.cos(a), r * math.sin(a), z))) + iring.append(bm.verts.new((ri * math.cos(a), ri * math.sin(a), z))) + outers.append(oring) + inners.append(iring) + for z_off, r in rim: + z = POT_Z0 + z_off + ri = max(r - POT_THICK, 0.014) + oring, iring = [], [] for i in range(N_AROUND): a = 2.0 * math.pi * i / N_AROUND - ov = bm.verts.new((r * math.cos(a), r * math.sin(a), z)) - iv = bm.verts.new((ri * math.cos(a), ri * math.sin(a), z)) - oring.append(ov) - iring.append(iv) - pot_verts.extend((ov, iv)) + oring.append(bm.verts.new((r * math.cos(a), r * math.sin(a), z))) + iring.append(bm.verts.new((ri * math.cos(a), ri * math.sin(a), z))) outers.append(oring) inners.append(iring) - for k in range(N_RINGS - 1): + for k in range(len(outers) - 1): for i in range(N_AROUND): j = (i + 1) % N_AROUND fo = bm.faces.new( @@ -574,106 +741,117 @@

Source

fi.material_index = METAL_IDX for i in range(N_AROUND): j = (i + 1) % N_AROUND - bot = bm.faces.new( - (outers[0][j], outers[0][i], inners[0][i], inners[0][j]) - ) - bot.material_index = METAL_IDX - rim = bm.faces.new( + lip = bm.faces.new( (outers[-1][i], outers[-1][j], inners[-1][j], inners[-1][i]) ) - rim.material_index = METAL_IDX + lip.material_index = METAL_IDX + oc = bm.verts.new((0.0, 0.0, POT_Z0)) + ic = bm.verts.new((0.0, 0.0, POT_Z0 + POT_THICK)) + for i in range(N_AROUND): + j = (i + 1) % N_AROUND + bot = bm.faces.new((oc, outers[0][j], outers[0][i])) + bot.material_index = METAL_IDX + ibot = bm.faces.new((ic, inners[0][i], inners[0][j])) + ibot.material_index = METAL_IDX - before = set(bm.faces) - add_cylinder( - bm, - (0.0, 0.0, POT_Z0 + POT_THICK * 0.6), - pot_radius(0.0) - POT_THICK * 0.2, - POT_THICK * 1.2, - 16, - METAL_IDX, - ) mouth_z = POT_Z0 + POT_H - add_rim(bm, (0.0, 0.0, mouth_z), R_TOP + 0.008, 0.012, METAL_IDX) - ear_x = R_TOP + 0.012 + # Vertical rings (hole along Y) so the XZ bail can thread them. + ear_x = R_TOP + 0.008 + ear_z = mouth_z + 0.002 for sign in (-1.0, 1.0): - add_box(bm, (sign * ear_x, 0.0, mouth_z - 0.01), (0.024, 0.030, 0.040), METAL_IDX) - add_rim( + add_torus( bm, - (sign * (ear_x + 0.004), 0.0, mouth_z + 0.008), - 0.014, - 0.005, + (sign * ear_x, 0.0, ear_z), + EAR_MAJOR, + EAR_MINOR, + 14, + 6, METAL_IDX, - euler=(math.radians(90.0), 0.0, 0.0), + euler=(math.pi / 2.0, 0.0, 0.0), ) - bail_major = ear_x + 0.004 + # Semicircle through the ear holes, ends past the rings. + t0 = -0.42 + t1 = math.pi + 0.42 bail_pts = [] for i in range(BAIL_SEGS + 1): - t = math.pi * i / BAIL_SEGS + t = t0 + (t1 - t0) * i / BAIL_SEGS bail_pts.append( - ( - bail_major * math.cos(t), - 0.0, - mouth_z + 0.008 + bail_major * math.sin(t), - ) + (ear_x * math.cos(t), 0.0, ear_z + ear_x * math.sin(t)) ) - for a, b in zip(bail_pts, bail_pts[1:]): - add_oriented_box(bm, a, b, (BAIL_R * 2.0, BAIL_R * 2.0), METAL_IDX) - metal_faces.update(set(bm.faces) - before) - - pole_feet = [] - for i in range(3): - ang = i * (2.0 * math.pi / 3.0) + math.radians(18.0) - foot = Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016)) - apex = Vector((0.0, 0.0, APEX_Z)) - pole_feet.append(foot) - wood_verts.extend(add_cone_between(bm, foot, apex, 0.028, 0.018, 8, WOOD_IDX)) - before = set(bm.faces) - add_cone_between( - bm, - foot + Vector((0.0, 0.0, 0.008)), - foot - Vector((0.0, 0.0, 0.014)), - 0.032, - 0.032, - 8, - METAL_IDX, - ) - metal_faces.update(set(bm.faces) - before) - - wood_verts.extend(add_cylinder(bm, (0.0, 0.0, APEX_Z - 0.02), 0.042, 0.055, 10, WOOD_IDX)) - before = set(bm.faces) - hook_z1 = APEX_Z - 0.055 - bail_peak = mouth_z + 0.008 + bail_major - add_cone_between( + add_pipe_curve(bm, bail_pts, BAIL_R, BAIL_PIPE, METAL_IDX) + bail_peak = Vector(bail_pts[len(bail_pts) // 2]) + hook_z = bail_peak.z + (0.08 if float_hook else 0.0) + add_torus( bm, - (0.0, 0.0, hook_z1), - (0.0, 0.0, bail_peak + 0.012), - 0.010, - 0.010, - 8, + (0.0, 0.0, hook_z), + BAIL_R + HOOK_R * 0.85, + HOOK_R, + 12, + 6, METAL_IDX, + euler=(0.0, math.radians(90.0), 0.0), ) - add_rim( + crown_z0 = APEX_Z - CROWN_H + lathe_axis( bm, - (0.0, 0.0, bail_peak + 0.004), - 0.018, - 0.006, + (0.0, 0.0, hook_z + HOOK_R), + (0.0, 0.0, crown_z0 + 0.008), + ((0.0, HOOK_R * 0.9), (1.0, HOOK_R * 0.9)), + 8, METAL_IDX, - euler=(math.radians(90.0), 0.0, 0.0), ) - metal_faces.update(set(bm.faces) - before) - - if bevel_offset > 0.0: - edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid}) - if edges: - bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) + + lathe_z( + bm, + ( + (0.000, 0.032), + (0.018, 0.046), + (0.048, 0.042), + (CROWN_H, 0.026), + ), + 16, + WOOD_IDX, + z0=crown_z0, + ) + feet = pole_feet() + ferrule_z0 = 0.05 if short_legs else 0.0 + if short_legs: + geo = bmesh.ops.create_cube(bm, size=1.0) + for v in geo["verts"]: + v.co.x *= 0.024 + v.co.y *= 0.024 + v.co.z *= 0.006 + v.co.z += 0.003 + for f in {f for v in geo["verts"] for f in v.link_faces}: + f.material_index = METAL_IDX + for foot in feet: + ang = math.atan2(foot.y, foot.x) + top = Vector(( + INSERT_R * math.cos(ang), + INSERT_R * math.sin(ang), + APEX_Z - 0.030, + )) + origin = Vector((foot.x, foot.y, ferrule_z0)) + tangent = (top - origin).normalized() + r_in = POLE_R_FOOT + 0.002 + r_out = r_in + FERRULE_T + lean = math.sqrt(max(0.0, 1.0 - tangent.z * tangent.z)) + shifted = origin + tangent * ((r_out * lean) / max(tangent.z, 0.25)) + add_cup_along( + bm, origin, tangent, r_in, r_out, FERRULE_H, + FERRULE_SEGS, METAL_IDX, + ) + # Same shoe either way so AABB/zmin stay put. --pipe-ferrule + # starts the pole above the well; ferrule_bite only samples + # wood inside the cup's own axis span, so a hovering pole + # cannot fake a seat. + seat = FERRULE_H + 0.05 if pipe_ferrule else (FERRULE_FLOOR + 0.002) + bot = shifted + tangent * seat + lathe_axis( + bm, bot, top, + ((0.0, POLE_R_FOOT), (1.0, POLE_R_TOP)), + POLE_SEGS, WOOD_IDX, + ) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -682,11 +860,8 @@

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(70.0): + if edge.calc_face_angle() > math.radians(55.0): edge.smooth = False - for f in metal_faces: - if f.is_valid: - f.material_index = METAL_IDX me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() @@ -696,27 +871,45 @@

Source

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 def assign_slots(obj, wood, metal): mats = obj.data.materials - if len(mats) == 0: - mats.append(wood) - mats.append(metal) - return - mats[0] = wood - if len(mats) == 1: - mats.append(metal) - else: - mats[1] = metal + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) def world_bbox(obj): @@ -728,15 +921,7 @@

Source

def pot_tripod_clearance(mesh): - # Recomputed from verts vs the three pole axes. Leg verts sit on the - # axes and are skipped; remaining shell verts in the pot band must - # clear the pole radius. - feet = [] - for i in range(3): - ang = i * (2.0 * math.pi / 3.0) + math.radians(18.0) - feet.append( - Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016)) - ) + feet = pole_feet() apex = Vector((0.0, 0.0, APEX_Z)) z0 = POT_Z0 - 0.02 z1 = POT_Z0 + POT_H + 0.03 @@ -756,7 +941,7 @@

Source

continue t = max(0.0, min(1.0, (p - foot).dot(ab) / denom)) axis = (p - (foot + t * ab)).length - rad = 0.028 * (1.0 - t) + 0.018 * t + rad = POLE_R_FOOT * (1.0 - t) + POLE_R_TOP * t gap = axis - rad if axis < rad + 0.006: on_pole = True @@ -795,6 +980,268 @@

Source

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): + vs = (me.vertices[idxs[i]].co, me.vertices[idxs[i + 1]].co) + area += (vs[0] - v0).cross(vs[1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + 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) + cups = [] + 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 a[5] < 0.14 and dx > 0.03 and dy > 0.03 and dz < 0.12: + cups.append(a) + cup_z = min((a[2] for a in cups), default=99.0) + return {"cups": len(cups), "cup_z": cup_z} + + +def pot_audit(me): + groups = shells(me) + best = None + best_r = -1.0 + 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] + r = 0.5 * 0.5 * (dx + dy) + if dz > 0.20 and r > best_r: + best_r = r + best = a + if best is None: + return {"dia": 0.0, "height": 0.0} + return { + "dia": 0.5 * ((best[3] - best[0]) + (best[4] - best[1])), + "height": best[5] - best[2], + } + + +def hook_bail_join(me): + """Worst gap from the compact hook ring to the bail pipe.""" + groups = shells(me) + hooks, bails = [], [] + 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] + cz = 0.5 * (a[2] + a[5]) + if cz > 0.55 and max(dx, dy, dz) < 0.08: + hooks.append(g) + elif dz > 0.12 and a[2] > 0.35 and max(dx, dy) > 0.20: + bails.append(g) + if not hooks or not bails: + return 99.0 + bm_b = bmesh.new() + try: + bm_b.from_mesh(me) + keep = set() + for g in bails: + keep.update(g) + drop = [ + f for f in bm_b.faces + if not all(v.index in keep for v in f.verts) + ] + if drop: + bmesh.ops.delete(bm_b, geom=drop, context="FACES") + if not bm_b.faces: + return 99.0 + tree = BVHTree.FromBMesh(bm_b) + worst = 0.0 + for g in hooks: + bm_h = bmesh.new() + try: + bm_h.from_mesh(me) + member = set(g) + drop_h = [ + f for f in bm_h.faces + if not all(v.index in member for v in f.verts) + ] + if drop_h: + bmesh.ops.delete(bm_h, geom=drop_h, context="FACES") + if not bm_h.faces: + worst = max(worst, 99.0) + continue + tree_h = BVHTree.FromBMesh(bm_h) + if tree.overlap(tree_h): + continue + best = 99.0 + for i in g: + hit = tree.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + if best > worst: + worst = best + finally: + bm_h.free() + return worst + finally: + bm_b.free() + + +def ferrule_bite(me): + """Pole radius minus cup inner wall, in the pole's own frame. + + World-XY distance lies about a leaning pole that is coaxial with its + shoe: the foot sits on the axis while the rim is offset in XY, so a + vertical-bucket metric reports a false gap. Measure radial distance + from the pole axis instead. + """ + groups = shells(me) + cups, woods = [], [] + for g in groups: + a = shell_aabb(me, g) + if mat_of(me, g) == METAL_IDX and a[5] < 0.12 and (a[5] - a[2]) < 0.10: + cups.append(g) + elif mat_of(me, g) == WOOD_IDX and a[2] < 0.12 and (a[5] - a[2]) > 0.15: + woods.append(g) + if not cups or not woods: + return 0.0 + feet = pole_feet() + apex = Vector((0.0, 0.0, APEX_Z)) + wood_pts = [me.vertices[i].co.copy() for g in woods for i in g] + bites = [] + for g in cups: + pts = [me.vertices[i].co.copy() for i in g] + centroid = sum(pts, Vector((0.0, 0.0, 0.0))) / len(pts) + foot = min(feet, key=lambda f: (f - centroid).length) + axis = apex - foot + if axis.length < 1e-8: + continue + axis.normalize() + + def rad(p): + return (p - foot).cross(axis).length + + wall = [rad(p) for p in pts if rad(p) > 0.010] + if not wall: + continue + r_in = min(wall) + span = [(p - foot).dot(axis) for p in pts] + t0, t1 = min(span), max(span) + near = [ + p for p in wood_pts + if rad(p) > 0.010 + and (p - centroid).length < 0.10 + and (t0 - 0.002) <= (p - foot).dot(axis) <= (t1 + 0.002) + ] + if not near: + continue + r_wood = min(rad(p) for p in near) + bites.append(r_wood - r_in) + if not bites: + return 0.05 + return min(bites) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, APEX_Z)) + 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) @@ -808,7 +1255,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: @@ -831,7 +1277,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("CauldronNrm", size, size, alpha=True, float_buffer=False) @@ -846,7 +1291,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" @@ -871,7 +1315,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: @@ -887,14 +1330,39 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_legs=False, + float_hook=False, + pipe_ferrule=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_cauldron_mesh("CauldronLow", bevel_offset=0.003, bevel_segments=2) - high = build_cauldron_mesh("CauldronHigh", bevel_offset=0.003, bevel_segments=4) - wood = principled("TripodWood", (0.38, 0.22, 0.09, 1.0), 0.0, 0.58) - metal = principled("CauldronIron", (0.10, 0.095, 0.09, 1.0), 0.92, 0.40) + flags = dict( + short_legs=short_legs, + float_hook=float_hook, + pipe_ferrule=pipe_ferrule, + ) + low = build_cauldron_mesh("CauldronLow", **flags) + high = build_cauldron_mesh("CauldronHigh", **flags) + wood = principled( + "TripodWood", (0.38, 0.22, 0.09, 1.0), 0.0, 0.58, + noise_scale=6.0, wear=(0.22, 0.12, 0.05, 1.0), + ) + metal = principled( + "CauldronIron", (0.10, 0.095, 0.09, 1.0), 0.92, 0.40, + noise_scale=5.0, wear=(0.18, 0.16, 0.14, 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("cauldron mesh did not build", 3), None, None, None, None, None @@ -934,7 +1402,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_cauldron_mesh("CauldronColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_cauldron_mesh("CauldronColSrc", **flags) collider = convex_hull_collider(collider_src, "CauldronCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -948,9 +1416,14 @@

Source

export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + pot = pot_audit(low.data) + hj = hook_bail_join(low.data) + bite = ferrule_bite(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -967,6 +1440,16 @@

Source

f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" + ) + print( + f"measured supports cups={sup['cups']} cup_z={sup['cup_z']:.5f} " + f"pot_dia={pot['dia']:.4f} pot_h={pot['height']:.4f} " + f"hook_join={hj:.5f} ferrule_bite={bite:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -1031,6 +1514,51 @@

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 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, + ), None, None, None, None, None + if sup["cups"] < 3 or sup["cup_z"] > FERRULE_Z_MAX: + return fail( + f"ferrule supports {sup['cups']} cup_z={sup['cup_z']:.5f} " + "(--short-legs is the designed fail)", + 16, + ), None, None, None, None, None + if hj > HOOK_JOIN: + return fail( + f"hook-bail gap {hj:.5f} > {HOOK_JOIN} " + "(--float-hook is the designed fail)", + 17, + ), None, None, None, None, None + if not (FERRULE_BITE_MIN <= bite <= FERRULE_BITE_MAX): + return fail( + f"ferrule bite {bite:.5f} not in " + f"[{FERRULE_BITE_MIN}, {FERRULE_BITE_MAX}] " + "(--pipe-ferrule is the designed fail)", + 18, + ), None, None, None, None, None + if abs(pot["dia"] - BODY_DIA) > BODY_DIA_TOL: + return fail( + f"pot diameter {pot['dia']:.4f} off {BODY_DIA}", + 19, + ), None, None, None, None, None + if abs(pot["height"] - BODY_H) > BODY_H_TOL: + return fail( + f"pot height {pot['height']:.4f} off {BODY_H}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -1144,14 +1672,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("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--short-legs", action="store_true") + p.add_argument("--float-hook", action="store_true") + p.add_argument("--pipe-ferrule", 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_legs=args.short_legs, + float_hook=args.float_hook, + pipe_ferrule=args.pipe_ferrule, + ) if code: return code if args.output: diff --git a/showcase/iron-cauldron/README.md b/showcase/iron-cauldron/README.md index 34857e2..9a77d25 100644 --- a/showcase/iron-cauldron/README.md +++ b/showcase/iron-cauldron/README.md @@ -1,9 +1,16 @@ # Iron cauldron -A showcase piece, not an example. Procedural hanging cauldron (bulged -iron pot with an open mouth, bail, timber tripod) 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 hanging cauldron (lathed +iron pot with a rolled rim, pipe bail through vertical ear rings, timber +tripod tenoned into a turned crown, iron ferrule cups coaxial with the +poles) then the shipped pipeline: unique-cell UVs, Cycles high-to-low +normal bake, LOD chain, convex collider, Unity glTF export. + +Ferrules are lofted along the pole axis and shifted so the downhill rim +sits at Z=0. A world-Z bucket lets a leaning pole slice the lip. +`ferrule_bite` is measured in that same frame, and only samples wood +inside the cup's own axis span — a pole hovering above the well cannot +fake a seat. It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -21,41 +28,83 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 3280–3520 | 3388 / 3388 / 3388 | +| Base triangles | 2800–4200 | 3664 / 3664 / 3664 | | 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.2172 | -| Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 450 wood, 1198 metal | +| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 | +| Materials | exactly 2 distinct; ≥80 wood, ≥200 metal faces | 2 slots; 224 / 1768 | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.013, 0.986, 0.827) m ± 0.01 | (1.0128, 0.9865, 0.8271), zmin 0.002 | -| Pot–leg clearance | ≥ 0.04 m (recomputed vs pole axes) | 0.0513 / 0.0513 / 0.0513 | -| Collider tris | ≤ 200 | 98 | -| Export | written, size > 0 | 245060 / 245060 / 245044 bytes | +| Outer AABB | (0.987, 0.961, 0.860) m ± 0.015 | (0.9866, 0.9605, 0.8600), zmin 0 | +| Collider tris | ≤ 280 | 250 | +| Export | written, size > 0 | 287916 / 287916 / 287904 bytes | + +Base triangles rose from **3388 to 3664** in the quality pass: coaxial +ferrule cups and a dual-wall lathe with a rolled rim replaced the old +cone-tripod / overlapping-torus construction. + +### 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: 3 ferrules | each `zmin` ≤ 1e-3 | 3, cup_z 0.00000 | +| Pot size | 0.355 m dia × 0.334 m high ± 0.04 | 0.3545 × 0.3340 | +| Pot–leg clearance | ≥ 0.04 m | 0.0464 | + +### Joint fit and seat + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Hook-bail BVH gap | ≤ 0.010 m | 0.00000 | +| Ferrule bite (pole r − inner wall, pole frame) | −0.006–0.000 m | −0.00185 | DECIMATE COLLAPSE triangle counts are **not** identical across series — -5.2.1 is 8 tris leaner on LOD2. The gate is a ratio band, not an exact -count. Bake pixels are stochastic; the gate is `has_data` plus operator -`FINISHED`, not byte-identity. Construction uses no RNG. Export byte -counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis. -The pot–leg clearance is recomputed from shell verts vs the three pole -axes; clipping exits 3. +the gate is a ratio band, not an exact count. Bake pixels are +stochastic; the gate is `has_data` plus operator `FINISHED`, not +byte-identity. Construction uses no RNG. Export byte counts differ by +12 B on 5.2.1 (glTF serializer), not a gated axis. -`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and -exit 9 fires. That is the named budget the falsifier violates. +### Falsifiers + +Each violates one named budget. All seven 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-legs` | named ferrule supports at Z=0 | 16 | +| `--float-hook` | hook-bail gap | 17 | +| `--pipe-ferrule` | ferrule bite (pole starts above the well) | 18 | ## Run ```bash blender --background --python iron_cauldron.py -- blender --background --python iron_cauldron.py -- --skip-decimate +blender --background --python iron_cauldron.py -- --stray-vert +blender --background --python iron_cauldron.py -- --lift-z +blender --background --python iron_cauldron.py -- --short-legs +blender --background --python iron_cauldron.py -- --float-hook +blender --background --python iron_cauldron.py -- --pipe-ferrule blender --background --python iron_cauldron.py -- --output cauldron.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 | | --- | --- | @@ -64,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 / pot clips tripod | | 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 | @@ -74,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 ferrule floats | +| 17 | Joint fit: hook-bail gap | +| 18 | Seat: ferrule bite band | +| 19 | Pot diameter or height off the stated real-world size | diff --git a/showcase/iron-cauldron/iron_cauldron.py b/showcase/iron-cauldron/iron_cauldron.py index 5f7cf5c..a8e28fc 100644 --- a/showcase/iron-cauldron/iron_cauldron.py +++ b/showcase/iron-cauldron/iron_cauldron.py @@ -1,12 +1,21 @@ -"""Game-ready iron cauldron — a showcase piece, not an example. +"""Game-ready hanging iron cauldron — a showcase piece, not an example. -Asserts budget conformance of a procedural hanging cauldron after composing -shipped pipeline pieces: bmesh construction, UVs, two materials, high-to-low -normal bake, LOD chain, convex collider, Unity glTF export. +Asserts budget conformance of a procedural cauldron (lathed pot with a +rolled rim, pipe bail through ear rings, timber tripod tenoned into a +turned crown, iron ferrule cups) 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 an 8-sided cone tripod piercing a 10-gon cap, a bail +of 12 boxes, a torus rim overlapping the lathe, and ferrules floating +2 mm off the ground. 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-legs`` named ferrule +supports, ``--float-hook`` hook-bail joint-fit, ``--pipe-ferrule`` +ferrule-cup seat (pole starts above the well). No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -26,10 +35,8 @@ import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree -# Showcase lives at repo-root/showcase/, not under examples/. The framing -# helper is the repo's only shared import and lives next to the examples; -# resolve the repo root so we do not move gallery_framing.py. _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) ) @@ -37,25 +44,48 @@ sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -POT_H = 0.32 -POT_Z0 = 0.14 -POT_THICK = 0.014 +# Camp cauldron ~40 cm across, hanging from an ~85 cm timber tripod +# whose feet land on a ~1.0 m circle. The pot's own size is gated +# separately from the outer AABB (the AABB is the tripod). +POT_H = 0.320 +POT_THICK = 0.012 N_AROUND = 32 N_RINGS = 12 -R_BOT = 0.050 -R_MID = 0.205 +R_BOT = 0.028 +R_MID = 0.185 R_TOP = 0.148 -APEX_Z = 0.82 -TRIPOD_R = 0.56 -BAIL_SEGS = 12 -BAIL_R = 0.009 -BBOX_TOL = 0.01 +POT_Z0 = 0.155 +APEX_Z = 0.860 +TRIPOD_R = 0.560 +POLE_SEGS = 16 +POLE_R_FOOT = 0.024 +POLE_R_TOP = 0.016 +FERRULE_H = 0.034 +FERRULE_T = 0.006 +FERRULE_SEGS = 16 +FERRULE_FLOOR = 0.004 +CROWN_H = 0.072 +CROWN_R = 0.046 +INSERT_R = 0.026 +BAIL_SEGS = 24 +BAIL_PIPE = 8 +BAIL_R = 0.007 +HOOK_R = 0.006 +EAR_MAJOR = 0.018 +EAR_MINOR = 0.0045 +POLE_OFFSET = math.radians(18.0) + +BBOX_TOL = 0.015 +OUTER_SIZE = (0.987, 0.961, 0.860) +BODY_DIA = 0.355 +BODY_DIA_TOL = 0.04 +BODY_H = 0.334 +BODY_H_TOL = 0.04 POT_LEG_CLEARANCE_MIN = 0.04 -# Fitted after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (1.013, 0.986, 0.827) -BASE_TRIS_MIN = 3280 -BASE_TRIS_MAX = 3520 +BASE_TRIS_MIN = 2800 +BASE_TRIS_MAX = 4200 +COLLIDER_TRIS_MAX = 280 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -65,22 +95,31 @@ MATERIAL_COUNT = 2 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 200 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 -WOOD_FACES_MIN = 12 +METAL_FACES_MIN = 200 +WOOD_FACES_MIN = 80 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +FERRULE_Z_MAX = 1e-3 +HOOK_JOIN = 0.010 +FERRULE_BITE_MIN = -0.006 +FERRULE_BITE_MAX = 0.000 WOOD_IDX = 0 METAL_IDX = 1 def eevee_engine_id(): - return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + return "BLENDER_EEVEE_NEXT" if bpy.app.version >= (4, 2, 0) else "BLENDER_EEVEE" def fail(msg, code): - print(f"ERROR: {msg}", file=sys.stderr) + print(f"FAIL[{code}]: {msg}", file=sys.stderr) return code @@ -90,92 +129,154 @@ 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() + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() try: - eval_mesh.calc_loop_triangles() - return len(eval_mesh.loop_triangles) + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) finally: - eval_obj.to_mesh_clear() + ev.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 _bridge_rings(bm, a, b, mat_idx): + if len(a) == 1 and len(b) > 1: + c = a[0] + for k in range(len(b)): + kn = (k + 1) % len(b) + face = bm.faces.new((c, b[k], b[kn])) + face.material_index = mat_idx + elif len(b) == 1 and len(a) > 1: + c = b[0] + for k in range(len(a)): + kn = (k + 1) % len(a) + face = bm.faces.new((c, a[kn], a[k])) + face.material_index = mat_idx + else: + segs = len(a) + for k in range(segs): + kn = (k + 1) % segs + face = bm.faces.new((a[k], a[kn], b[kn], b[k])) + face.material_index = mat_idx + + +def loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True, cyclic=False): + verts = [v for ring in rings for v in ring] + n = len(rings) + for i in range(n - 1): + _bridge_rings(bm, rings[i], rings[i + 1], mat_idx) + if cyclic and n > 2: + _bridge_rings(bm, rings[-1], rings[0], mat_idx) + if cap_start and len(rings[0]) > 1: + ring = rings[0] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[kn], ring[k])) + face.material_index = mat_idx + if cap_end and len(rings[-1]) > 1: + ring = rings[-1] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[k], ring[kn])) + face.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( - bm, - ((a + b) * 0.5), - (scale_xy[0], scale_xy[1], length), - mat_idx, - euler=(eul.x, eul.y, eul.z), - ) +def lathe_z(bm, profile, segs, mat_idx, z0=0.0): + rings = [] + for z, r in profile: + if r <= 1e-8: + rings.append([bm.verts.new((0.0, 0.0, z0 + z))]) + continue + ring = [] + for i in range(segs): + a = 2.0 * math.pi * i / segs + ring.append(bm.verts.new((r * math.cos(a), r * math.sin(a), z0 + z))) + rings.append(ring) + cap_start = len(rings[0]) > 1 + cap_end = len(rings[-1]) > 1 + return loft_rings(bm, rings, mat_idx, cap_start=cap_start, cap_end=cap_end) -def add_cone_between(bm, a, b, r1, r2, segments, mat_idx): +def lathe_axis(bm, a, b, profile, segs, mat_idx, cap_start=True, cap_end=True): a = Vector(a) b = Vector(b) delta = b - a length = delta.length if length < 1e-8: return [] - quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) - eul = quat.to_euler("XYZ") - return add_cone( - bm, (a + b) * 0.5, r1, r2, length, segments, mat_idx, - euler=(eul.x, eul.y, eul.z), - ) - - -def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( - bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + tangent = delta.normalized() + side = Vector((-tangent.y, tangent.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = tangent.cross(side).normalized() + rings = [] + for t, radius in profile: + p = a + tangent * (t * length) + if radius <= 1e-8: + rings.append([bm.verts.new(p)]) + continue + ring = [] + for i in range(segs): + ang = 2.0 * math.pi * i / segs + ring.append( + bm.verts.new( + p + + side * (radius * math.cos(ang)) + + up * (radius * math.sin(ang)) + ) + ) + rings.append(ring) + return loft_rings(bm, rings, mat_idx, cap_start=cap_start, cap_end=cap_end) + + +def add_cup_along(bm, origin, tangent, r_in, r_out, height, segs, mat_idx, floor=FERRULE_FLOOR): + """Closed iron shoe coaxial with the pole. + + `origin` is the intended ground station of the pole foot. The cup is + shifted along `tangent` so the downhill outer rim sits at Z=0 — + a world-Z bucket lets a leaning pole slice the lip. Returns the + shifted origin (axis point of the outer floor). + """ + t = Vector(tangent).normalized() + o = Vector(origin) + side = Vector((-t.y, t.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = t.cross(side).normalized() + lean = math.sqrt(max(0.0, 1.0 - t.z * t.z)) + o = o + t * ((r_out * lean) / max(t.z, 0.25)) + specs = ( + (0.0, r_out), + (height, r_out), + (height, r_in), + (floor, r_in), ) - verts = list(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_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) + rings = [] + for dist, r in specs: + p = o + t * dist + ring = [] + for i in range(segs): + a = 2.0 * math.pi * i / segs + ring.append( + bm.verts.new( + p + side * (r * math.cos(a)) + up * (r * math.sin(a)) + ) + ) + rings.append(ring) + loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True) + return o, t -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 12 - n_minor = 6 +def add_torus(bm, loc, major, minor, n_major, n_minor, mat_idx, euler=(0.0, 0.0, 0.0)): rings = [] for i in range(n_major): u = i * (2.0 * math.pi / n_major) @@ -187,6 +288,7 @@ def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): z = minor * math.sin(v) ring.append(bm.verts.new((x, y, z))) rings.append(ring) + verts = [v for ring in rings for v in ring] for i in range(n_major): i2 = (i + 1) % n_major for j in range(n_minor): @@ -195,7 +297,6 @@ def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): (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: @@ -203,6 +304,33 @@ def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): return verts +def add_pipe_curve(bm, points, radius, segs, mat_idx): + rings = [] + n = len(points) + for i, p in enumerate(points): + p = Vector(p) + if i < n - 1: + tangent = (Vector(points[i + 1]) - p).normalized() + else: + tangent = (p - Vector(points[i - 1])).normalized() + side = Vector((-tangent.y, tangent.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = tangent.cross(side).normalized() + ring = [] + for k in range(segs): + a = 2.0 * math.pi * k / segs + ring.append( + bm.verts.new( + p + side * (radius * math.cos(a)) + up * (radius * math.sin(a)) + ) + ) + rings.append(ring) + return loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True) + + def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) @@ -219,9 +347,7 @@ def pack_uvs(bm, margin=0.08): col = i % cols row = i // cols nrm = face.normal - ax = abs(nrm.x) - ay = abs(nrm.y) - az = abs(nrm.z) + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) coords = [] for loop in face.loops: co = loop.vert.co @@ -248,36 +374,63 @@ def pack_uvs(bm, margin=0.08): def pot_radius(z_local): t = max(0.0, min(1.0, z_local / POT_H)) - return (1.0 - t) ** 2 * R_BOT + 2.0 * t * (1.0 - t) * (R_MID * 1.35) + t ** 2 * R_TOP + return ( + (1.0 - t) ** 2 * R_BOT + + 2.0 * t * (1.0 - t) * (R_MID * 1.32) + + t ** 2 * R_TOP + ) + + +def pole_feet(): + feet = [] + for i in range(3): + ang = i * (2.0 * math.pi / 3.0) + POLE_OFFSET + feet.append(Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.0))) + return feet -def build_cauldron_mesh(name, bevel_offset, bevel_segments): +def build_cauldron_mesh( + name, + short_legs=False, + float_hook=False, + pipe_ferrule=False, +): bm = bmesh.new() wood_verts = [] - metal_faces = set() - pot_verts = [] try: - # Shared-vertex lathe: one continuous iron shell, not overlapping - # stave panels (those read as a gold grid under studio lighting). + # Dual-wall lathe plus a rolled rim. No overlapping torus, no + # extra bottom cylinder — those left a hole in the floor and a + # faceted lip sitting on the mouth. zs = [POT_H * i / (N_RINGS - 1) for i in range(N_RINGS)] - outers = [] - inners = [] + rim = ( + (POT_H + 0.004, R_TOP + 0.012), + (POT_H + 0.010, R_TOP + 0.018), + (POT_H + 0.014, R_TOP + 0.014), + (POT_H + 0.011, R_TOP + 0.006), + ) + outers, inners = [], [] for z_local in zs: z = POT_Z0 + z_local r = pot_radius(z_local) - ri = max(r - POT_THICK, 0.018) - oring = [] - iring = [] + ri = max(r - POT_THICK, 0.014) + oring, iring = [], [] + for i in range(N_AROUND): + a = 2.0 * math.pi * i / N_AROUND + oring.append(bm.verts.new((r * math.cos(a), r * math.sin(a), z))) + iring.append(bm.verts.new((ri * math.cos(a), ri * math.sin(a), z))) + outers.append(oring) + inners.append(iring) + for z_off, r in rim: + z = POT_Z0 + z_off + ri = max(r - POT_THICK, 0.014) + oring, iring = [], [] for i in range(N_AROUND): a = 2.0 * math.pi * i / N_AROUND - ov = bm.verts.new((r * math.cos(a), r * math.sin(a), z)) - iv = bm.verts.new((ri * math.cos(a), ri * math.sin(a), z)) - oring.append(ov) - iring.append(iv) - pot_verts.extend((ov, iv)) + oring.append(bm.verts.new((r * math.cos(a), r * math.sin(a), z))) + iring.append(bm.verts.new((ri * math.cos(a), ri * math.sin(a), z))) outers.append(oring) inners.append(iring) - for k in range(N_RINGS - 1): + for k in range(len(outers) - 1): for i in range(N_AROUND): j = (i + 1) % N_AROUND fo = bm.faces.new( @@ -290,106 +443,117 @@ def build_cauldron_mesh(name, bevel_offset, bevel_segments): fi.material_index = METAL_IDX for i in range(N_AROUND): j = (i + 1) % N_AROUND - bot = bm.faces.new( - (outers[0][j], outers[0][i], inners[0][i], inners[0][j]) - ) - bot.material_index = METAL_IDX - rim = bm.faces.new( + lip = bm.faces.new( (outers[-1][i], outers[-1][j], inners[-1][j], inners[-1][i]) ) - rim.material_index = METAL_IDX + lip.material_index = METAL_IDX + oc = bm.verts.new((0.0, 0.0, POT_Z0)) + ic = bm.verts.new((0.0, 0.0, POT_Z0 + POT_THICK)) + for i in range(N_AROUND): + j = (i + 1) % N_AROUND + bot = bm.faces.new((oc, outers[0][j], outers[0][i])) + bot.material_index = METAL_IDX + ibot = bm.faces.new((ic, inners[0][i], inners[0][j])) + ibot.material_index = METAL_IDX - before = set(bm.faces) - add_cylinder( - bm, - (0.0, 0.0, POT_Z0 + POT_THICK * 0.6), - pot_radius(0.0) - POT_THICK * 0.2, - POT_THICK * 1.2, - 16, - METAL_IDX, - ) mouth_z = POT_Z0 + POT_H - add_rim(bm, (0.0, 0.0, mouth_z), R_TOP + 0.008, 0.012, METAL_IDX) - ear_x = R_TOP + 0.012 + # Vertical rings (hole along Y) so the XZ bail can thread them. + ear_x = R_TOP + 0.008 + ear_z = mouth_z + 0.002 for sign in (-1.0, 1.0): - add_box(bm, (sign * ear_x, 0.0, mouth_z - 0.01), (0.024, 0.030, 0.040), METAL_IDX) - add_rim( + add_torus( bm, - (sign * (ear_x + 0.004), 0.0, mouth_z + 0.008), - 0.014, - 0.005, + (sign * ear_x, 0.0, ear_z), + EAR_MAJOR, + EAR_MINOR, + 14, + 6, METAL_IDX, - euler=(math.radians(90.0), 0.0, 0.0), + euler=(math.pi / 2.0, 0.0, 0.0), ) - bail_major = ear_x + 0.004 + # Semicircle through the ear holes, ends past the rings. + t0 = -0.42 + t1 = math.pi + 0.42 bail_pts = [] for i in range(BAIL_SEGS + 1): - t = math.pi * i / BAIL_SEGS + t = t0 + (t1 - t0) * i / BAIL_SEGS bail_pts.append( - ( - bail_major * math.cos(t), - 0.0, - mouth_z + 0.008 + bail_major * math.sin(t), - ) - ) - for a, b in zip(bail_pts, bail_pts[1:]): - add_oriented_box(bm, a, b, (BAIL_R * 2.0, BAIL_R * 2.0), METAL_IDX) - metal_faces.update(set(bm.faces) - before) - - pole_feet = [] - for i in range(3): - ang = i * (2.0 * math.pi / 3.0) + math.radians(18.0) - foot = Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016)) - apex = Vector((0.0, 0.0, APEX_Z)) - pole_feet.append(foot) - wood_verts.extend(add_cone_between(bm, foot, apex, 0.028, 0.018, 8, WOOD_IDX)) - before = set(bm.faces) - add_cone_between( - bm, - foot + Vector((0.0, 0.0, 0.008)), - foot - Vector((0.0, 0.0, 0.014)), - 0.032, - 0.032, - 8, - METAL_IDX, + (ear_x * math.cos(t), 0.0, ear_z + ear_x * math.sin(t)) ) - metal_faces.update(set(bm.faces) - before) - - wood_verts.extend(add_cylinder(bm, (0.0, 0.0, APEX_Z - 0.02), 0.042, 0.055, 10, WOOD_IDX)) - before = set(bm.faces) - hook_z1 = APEX_Z - 0.055 - bail_peak = mouth_z + 0.008 + bail_major - add_cone_between( + add_pipe_curve(bm, bail_pts, BAIL_R, BAIL_PIPE, METAL_IDX) + bail_peak = Vector(bail_pts[len(bail_pts) // 2]) + hook_z = bail_peak.z + (0.08 if float_hook else 0.0) + add_torus( bm, - (0.0, 0.0, hook_z1), - (0.0, 0.0, bail_peak + 0.012), - 0.010, - 0.010, - 8, + (0.0, 0.0, hook_z), + BAIL_R + HOOK_R * 0.85, + HOOK_R, + 12, + 6, METAL_IDX, + euler=(0.0, math.radians(90.0), 0.0), ) - add_rim( + crown_z0 = APEX_Z - CROWN_H + lathe_axis( bm, - (0.0, 0.0, bail_peak + 0.004), - 0.018, - 0.006, + (0.0, 0.0, hook_z + HOOK_R), + (0.0, 0.0, crown_z0 + 0.008), + ((0.0, HOOK_R * 0.9), (1.0, HOOK_R * 0.9)), + 8, METAL_IDX, - euler=(math.radians(90.0), 0.0, 0.0), ) - metal_faces.update(set(bm.faces) - before) - - if bevel_offset > 0.0: - edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid}) - if edges: - bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) + + lathe_z( + bm, + ( + (0.000, 0.032), + (0.018, 0.046), + (0.048, 0.042), + (CROWN_H, 0.026), + ), + 16, + WOOD_IDX, + z0=crown_z0, + ) + feet = pole_feet() + ferrule_z0 = 0.05 if short_legs else 0.0 + if short_legs: + geo = bmesh.ops.create_cube(bm, size=1.0) + for v in geo["verts"]: + v.co.x *= 0.024 + v.co.y *= 0.024 + v.co.z *= 0.006 + v.co.z += 0.003 + for f in {f for v in geo["verts"] for f in v.link_faces}: + f.material_index = METAL_IDX + for foot in feet: + ang = math.atan2(foot.y, foot.x) + top = Vector(( + INSERT_R * math.cos(ang), + INSERT_R * math.sin(ang), + APEX_Z - 0.030, + )) + origin = Vector((foot.x, foot.y, ferrule_z0)) + tangent = (top - origin).normalized() + r_in = POLE_R_FOOT + 0.002 + r_out = r_in + FERRULE_T + lean = math.sqrt(max(0.0, 1.0 - tangent.z * tangent.z)) + shifted = origin + tangent * ((r_out * lean) / max(tangent.z, 0.25)) + add_cup_along( + bm, origin, tangent, r_in, r_out, FERRULE_H, + FERRULE_SEGS, METAL_IDX, + ) + # Same shoe either way so AABB/zmin stay put. --pipe-ferrule + # starts the pole above the well; ferrule_bite only samples + # wood inside the cup's own axis span, so a hovering pole + # cannot fake a seat. + seat = FERRULE_H + 0.05 if pipe_ferrule else (FERRULE_FLOOR + 0.002) + bot = shifted + tangent * seat + lathe_axis( + bm, bot, top, + ((0.0, POLE_R_FOOT), (1.0, POLE_R_TOP)), + POLE_SEGS, WOOD_IDX, + ) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -398,11 +562,8 @@ def build_cauldron_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(70.0): + if edge.calc_face_angle() > math.radians(55.0): edge.smooth = False - for f in metal_faces: - if f.is_valid: - f.material_index = METAL_IDX me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() @@ -412,27 +573,45 @@ def build_cauldron_mesh(name, bevel_offset, bevel_segments): 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 def assign_slots(obj, wood, metal): mats = obj.data.materials - if len(mats) == 0: - mats.append(wood) - mats.append(metal) - return - mats[0] = wood - if len(mats) == 1: - mats.append(metal) - else: - mats[1] = metal + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) def world_bbox(obj): @@ -444,15 +623,7 @@ def world_bbox(obj): def pot_tripod_clearance(mesh): - # Recomputed from verts vs the three pole axes. Leg verts sit on the - # axes and are skipped; remaining shell verts in the pot band must - # clear the pole radius. - feet = [] - for i in range(3): - ang = i * (2.0 * math.pi / 3.0) + math.radians(18.0) - feet.append( - Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016)) - ) + feet = pole_feet() apex = Vector((0.0, 0.0, APEX_Z)) z0 = POT_Z0 - 0.02 z1 = POT_Z0 + POT_H + 0.03 @@ -472,7 +643,7 @@ def pot_tripod_clearance(mesh): continue t = max(0.0, min(1.0, (p - foot).dot(ab) / denom)) axis = (p - (foot + t * ab)).length - rad = 0.028 * (1.0 - t) + 0.018 * t + rad = POLE_R_FOOT * (1.0 - t) + POLE_R_TOP * t gap = axis - rad if axis < rad + 0.006: on_pole = True @@ -511,6 +682,268 @@ def uv_stats(mesh): 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): + vs = (me.vertices[idxs[i]].co, me.vertices[idxs[i + 1]].co) + area += (vs[0] - v0).cross(vs[1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + 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) + cups = [] + 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 a[5] < 0.14 and dx > 0.03 and dy > 0.03 and dz < 0.12: + cups.append(a) + cup_z = min((a[2] for a in cups), default=99.0) + return {"cups": len(cups), "cup_z": cup_z} + + +def pot_audit(me): + groups = shells(me) + best = None + best_r = -1.0 + 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] + r = 0.5 * 0.5 * (dx + dy) + if dz > 0.20 and r > best_r: + best_r = r + best = a + if best is None: + return {"dia": 0.0, "height": 0.0} + return { + "dia": 0.5 * ((best[3] - best[0]) + (best[4] - best[1])), + "height": best[5] - best[2], + } + + +def hook_bail_join(me): + """Worst gap from the compact hook ring to the bail pipe.""" + groups = shells(me) + hooks, bails = [], [] + 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] + cz = 0.5 * (a[2] + a[5]) + if cz > 0.55 and max(dx, dy, dz) < 0.08: + hooks.append(g) + elif dz > 0.12 and a[2] > 0.35 and max(dx, dy) > 0.20: + bails.append(g) + if not hooks or not bails: + return 99.0 + bm_b = bmesh.new() + try: + bm_b.from_mesh(me) + keep = set() + for g in bails: + keep.update(g) + drop = [ + f for f in bm_b.faces + if not all(v.index in keep for v in f.verts) + ] + if drop: + bmesh.ops.delete(bm_b, geom=drop, context="FACES") + if not bm_b.faces: + return 99.0 + tree = BVHTree.FromBMesh(bm_b) + worst = 0.0 + for g in hooks: + bm_h = bmesh.new() + try: + bm_h.from_mesh(me) + member = set(g) + drop_h = [ + f for f in bm_h.faces + if not all(v.index in member for v in f.verts) + ] + if drop_h: + bmesh.ops.delete(bm_h, geom=drop_h, context="FACES") + if not bm_h.faces: + worst = max(worst, 99.0) + continue + tree_h = BVHTree.FromBMesh(bm_h) + if tree.overlap(tree_h): + continue + best = 99.0 + for i in g: + hit = tree.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + if best > worst: + worst = best + finally: + bm_h.free() + return worst + finally: + bm_b.free() + + +def ferrule_bite(me): + """Pole radius minus cup inner wall, in the pole's own frame. + + World-XY distance lies about a leaning pole that is coaxial with its + shoe: the foot sits on the axis while the rim is offset in XY, so a + vertical-bucket metric reports a false gap. Measure radial distance + from the pole axis instead. + """ + groups = shells(me) + cups, woods = [], [] + for g in groups: + a = shell_aabb(me, g) + if mat_of(me, g) == METAL_IDX and a[5] < 0.12 and (a[5] - a[2]) < 0.10: + cups.append(g) + elif mat_of(me, g) == WOOD_IDX and a[2] < 0.12 and (a[5] - a[2]) > 0.15: + woods.append(g) + if not cups or not woods: + return 0.0 + feet = pole_feet() + apex = Vector((0.0, 0.0, APEX_Z)) + wood_pts = [me.vertices[i].co.copy() for g in woods for i in g] + bites = [] + for g in cups: + pts = [me.vertices[i].co.copy() for i in g] + centroid = sum(pts, Vector((0.0, 0.0, 0.0))) / len(pts) + foot = min(feet, key=lambda f: (f - centroid).length) + axis = apex - foot + if axis.length < 1e-8: + continue + axis.normalize() + + def rad(p): + return (p - foot).cross(axis).length + + wall = [rad(p) for p in pts if rad(p) > 0.010] + if not wall: + continue + r_in = min(wall) + span = [(p - foot).dot(axis) for p in pts] + t0, t1 = min(span), max(span) + near = [ + p for p in wood_pts + if rad(p) > 0.010 + and (p - centroid).length < 0.10 + and (t0 - 0.002) <= (p - foot).dot(axis) <= (t1 + 0.002) + ] + if not near: + continue + r_wood = min(rad(p) for p in near) + bites.append(r_wood - r_in) + if not bites: + return 0.05 + return min(bites) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, APEX_Z)) + 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) @@ -524,7 +957,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: @@ -547,7 +979,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("CauldronNrm", size, size, alpha=True, float_buffer=False) @@ -562,7 +993,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" @@ -587,7 +1017,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: @@ -603,14 +1032,39 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_legs=False, + float_hook=False, + pipe_ferrule=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_cauldron_mesh("CauldronLow", bevel_offset=0.003, bevel_segments=2) - high = build_cauldron_mesh("CauldronHigh", bevel_offset=0.003, bevel_segments=4) - wood = principled("TripodWood", (0.38, 0.22, 0.09, 1.0), 0.0, 0.58) - metal = principled("CauldronIron", (0.10, 0.095, 0.09, 1.0), 0.92, 0.40) + flags = dict( + short_legs=short_legs, + float_hook=float_hook, + pipe_ferrule=pipe_ferrule, + ) + low = build_cauldron_mesh("CauldronLow", **flags) + high = build_cauldron_mesh("CauldronHigh", **flags) + wood = principled( + "TripodWood", (0.38, 0.22, 0.09, 1.0), 0.0, 0.58, + noise_scale=6.0, wear=(0.22, 0.12, 0.05, 1.0), + ) + metal = principled( + "CauldronIron", (0.10, 0.095, 0.09, 1.0), 0.92, 0.40, + noise_scale=5.0, wear=(0.18, 0.16, 0.14, 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("cauldron mesh did not build", 3), None, None, None, None, None @@ -650,7 +1104,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_cauldron_mesh("CauldronColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_cauldron_mesh("CauldronColSrc", **flags) collider = convex_hull_collider(collider_src, "CauldronCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -664,9 +1118,14 @@ def check(skip_decimate): export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + pot = pot_audit(low.data) + hj = hook_bail_join(low.data) + bite = ferrule_bite(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -683,6 +1142,16 @@ def check(skip_decimate): f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" + ) + print( + f"measured supports cups={sup['cups']} cup_z={sup['cup_z']:.5f} " + f"pot_dia={pot['dia']:.4f} pot_h={pot['height']:.4f} " + f"hook_join={hj:.5f} ferrule_bite={bite:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -747,6 +1216,51 @@ 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 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, + ), None, None, None, None, None + if sup["cups"] < 3 or sup["cup_z"] > FERRULE_Z_MAX: + return fail( + f"ferrule supports {sup['cups']} cup_z={sup['cup_z']:.5f} " + "(--short-legs is the designed fail)", + 16, + ), None, None, None, None, None + if hj > HOOK_JOIN: + return fail( + f"hook-bail gap {hj:.5f} > {HOOK_JOIN} " + "(--float-hook is the designed fail)", + 17, + ), None, None, None, None, None + if not (FERRULE_BITE_MIN <= bite <= FERRULE_BITE_MAX): + return fail( + f"ferrule bite {bite:.5f} not in " + f"[{FERRULE_BITE_MIN}, {FERRULE_BITE_MAX}] " + "(--pipe-ferrule is the designed fail)", + 18, + ), None, None, None, None, None + if abs(pot["dia"] - BODY_DIA) > BODY_DIA_TOL: + return fail( + f"pot diameter {pot['dia']:.4f} off {BODY_DIA}", + 19, + ), None, None, None, None, None + if abs(pot["height"] - BODY_H) > BODY_H_TOL: + return fail( + f"pot height {pot['height']:.4f} off {BODY_H}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -860,14 +1374,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("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--short-legs", action="store_true") + p.add_argument("--float-hook", action="store_true") + p.add_argument("--pipe-ferrule", 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_legs=args.short_legs, + float_hook=args.float_hook, + pipe_ferrule=args.pipe_ferrule, + ) if code: return code if args.output: diff --git a/showcase/iron-cauldron/preview.webp b/showcase/iron-cauldron/preview.webp index 86c7d14076ae3ccd69e2492d18eacef4f5668663..1ce8a2ae0fb059a2e78d75a1182bb97aa38df0fd 100644 GIT binary patch literal 14484 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 S7R)ofDz21wli?}Wr~m+2A)_(? From 173621dbf2f4c210bdd389c03e913fa175ee1f78 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:59:46 -0400 Subject: [PATCH 2/2] fix(showcase): lock shipping-crate tenons and filleted handles as hygiene budgets Posts hung over a skid cave and 90-degree handle lofts sat on the plates. Shared post/skid planes and quarter-circle bails close those joints without magic offsets. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- docs/gallery/assets/shipping-crate-hero.webp | Bin 12172 -> 34240 bytes docs/gallery/index.html | 4 +- docs/gallery/shipping-crate/index.html | 893 +++++++++++++++---- showcase/gallery.json | 4 +- showcase/shipping-crate/README.md | 92 +- showcase/shipping-crate/preview.webp | Bin 11272 -> 19926 bytes showcase/shipping-crate/shipping_crate.py | 863 ++++++++++++++---- 7 files changed, 1524 insertions(+), 332 deletions(-) diff --git a/docs/gallery/assets/shipping-crate-hero.webp b/docs/gallery/assets/shipping-crate-hero.webp index 588b8d668f697b1812e98b1a261529fd03c96ff5..abcf26fc7f519a3b5e3d4ce4405bbc93998e5ed6 100644 GIT binary patch literal 34240 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 O`2c_bO~ZXtFaQAL3v&?w diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 3ec19a1..fbf6b53 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -892,7 +892,7 @@

gn-socket-rename

shipping-crate

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

-

witnesses Recomputed: 624 tris, two materials with 132 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.352×0.856×0.748 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 36 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 2508 tris, two materials with 1170 wood and 280 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.146×0.726×0.612 m, grounded zmin, 3 skids, handle-end and slat-post join 0, mesh hygiene all zero, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 76 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-skids 16; --float-handle 17; --omit-slats 18.

View example
@@ -1145,7 +1145,7 @@

tavern-stool

iron-cauldron

A procedural hanging iron cauldron with a bulged open pot, bail, and timber tripod through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.013×0.986×0.827 m, pot–leg clearance 0.051 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 98 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 3664 tris, two materials with 224 wood and 1768 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.987×0.961×0.860 m, pot–leg clearance 0.046 m, 3 coaxial ferrules at zmin 0, ferrule bite −0.00185 m, hook-bail join 0, mesh hygiene all zero, LOD ratios in band, convex collider 250 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-legs 16; --float-hook 17; --pipe-ferrule 18.

View example
diff --git a/docs/gallery/shipping-crate/index.html b/docs/gallery/shipping-crate/index.html index 5bb0ce7..13c584f 100644 --- a/docs/gallery/shipping-crate/index.html +++ b/docs/gallery/shipping-crate/index.html @@ -253,28 +253,42 @@

shipping-crate

shipping-crate render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 624 tris, two materials with 132 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.352×0.856×0.748 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 36 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 2508 tris, two materials with 1170 wood and 280 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.146×0.726×0.612 m, grounded zmin, 3 skids, handle-end and slat-post join 0, mesh hygiene all zero, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 76 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-skids 16; --float-handle 17; --omit-slats 18.
blender --background --python showcase/shipping-crate/shipping_crate.py --
-

A showcase piece, not an example. Procedural crate (beveled body, arrayed slats, iron corner brackets and end handles, two materials) 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 crate (skids under the posts, corner posts with tenoned slats and seeded width jitter, bottom sills, L-straps, filleted iron bail handles through mounting plates) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

Skids sit under the posts so the outer corner is a mortise, not a cave. Handle bails use quarter-circle corners so a 90-degree loft cannot sit the bar on the plate.

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 | 580–680 | 624 / 624 / 624 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4455 | | LOD2 ratio | 0.10–0.35 of base | 0.2179 / 0.2179 / 0.1571 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 180 wood, 132 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.352, 0.856, 0.748) m ± 0.01 | (1.3520, 0.8560, 0.7480), zmin 0 | | Collider tris | ≤ 48 | 36 | | Export | written, size > 0 | 50148 / 50148 / 50140 bytes |

-

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG.

-

--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 | 1200–2800 | 2508 / 2508 / 2508 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2057 | | Materials | exactly 2 distinct; ≥200 wood, ≥48 metal faces | 2 slots; 1170 / 280 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.146, 0.726, 0.612) m ± 0.015 | (1.1460, 0.7260, 0.6120), zmin 0 | | Collider tris | ≤ 220 | 76 | | Export | written, size > 0 | 197408 / 197408 / 197392 bytes |

+

Base triangles rose from 624 to 2508 in the quality pass: the old beveled cube with glued-on slats became a post-and-slat crate with tenons, sills, L-straps, and pipe handles.

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Slat-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: 3 skids | each zmin ≤ 1e-3 | 3, skid_z 0.00000 | | Body plan | 1.056 × 0.716 m ± 0.05 | 1.0560 × 0.7160 |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Handle-end BVH gap | ≤ 0.008 m | 0.00000 | | Slat-post BVH gap | ≤ 0.006 m | 0.00000 |

+

Falsifiers

+

Each violates one named budget. All seven 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-skids | named skid supports at Z=0 | 16 | | --float-handle | handle-end gap | 17 | | --omit-slats | slat-post seat | 18 |

Run

blender --background --python shipping_crate.py --
 blender --background --python shipping_crate.py -- --skip-decimate
+blender --background --python shipping_crate.py -- --stray-vert
+blender --background --python shipping_crate.py -- --lift-z
+blender --background --python shipping_crate.py -- --short-skids
+blender --background --python shipping_crate.py -- --float-handle
+blender --background --python shipping_crate.py -- --omit-slats
 blender --background --python shipping_crate.py -- --output crate.png
-

Smoke does not pass --output or --skip-decimate.

+

Smoke passes no flags.

Exit codes

-

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.

-

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 1519 are the hygiene and joint-fit family.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or a face-count floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named skid floats | | 17 | Joint fit: handle-end gap | | 18 | Seat: slat-post gap | | 19 | Body plan off the stated real-world size |

Source

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

Source

"""Game-ready shipping crate — a showcase piece, not an example.
 
-Asserts budget conformance of a procedural crate after composing shipped
-pipeline pieces: bmesh construction, UVs, two materials, high-to-low
-normal bake, LOD chain, convex collider, Unity glTF export.
+Asserts budget conformance of a procedural crate (skids, corner posts,
+tenoned slats with seeded width jitter, L-straps, iron bail handles)
+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 solid beveled cube with identical slats glued on
+and three overlapping cubes per corner that left window-holes through
+the iron.
 
 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-skids`` named skid
+supports, ``--float-handle`` handle-to-end joint-fit, ``--omit-slats``
+slat-to-post seat.
 
-No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+Fixed seed 17 for slat-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,6 +326,7 @@ 

Source

import argparse import math import os +import random import sys import tempfile import traceback @@ -310,10 +334,8 @@

Source

import bmesh import bpy 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,18 +343,33 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -# Body size (meters), sitting on z=0. Side slats sit proud on ±Y; lid -# slats sit proud on +Z. Iron handles sit proud on ±X. OUTER_SIZE is -# fitted after locking geometry against the measured world bbox. -BODY_SIZE = (1.20, 0.80, 0.72) -SLAT_THICK = 0.028 -OUTER_SIZE = (1.352, 0.856, 0.748) -BBOX_TOL = 0.01 - -# Measured after locking geometry. DECIMATE COLLAPSE ratios diverge -# across series — bands, not exact counts. -BASE_TRIS_MIN = 580 -BASE_TRIS_MAX = 680 +# A chest-sized shipping crate, ~96 × 62 × 58 cm inner, sitting on +# three skids. Outer AABB includes the proud L-straps and handles. +INNER = (0.96, 0.62, 0.52) +POST = 0.048 +SKID_H = 0.036 +SKID_W = 0.058 +SLAT_T = 0.016 +RAIL_H = 0.040 +TENON = 0.010 +IRON_T = 0.004 +IRON_WRAP = 0.072 +HANDLE_OUT = 0.038 +HANDLE_R = 0.007 +N_FLOOR = 6 +N_LID = 6 +N_LONG = 5 +N_END = 4 +SLAT_SEED = 17 +SLAT_JITTER = 0.045 + +BBOX_TOL = 0.015 +OUTER_SIZE = (1.146, 0.726, 0.612) +BODY_X, BODY_Y, BODY_Z = 1.056, 0.716, 0.576 +BODY_TOL = 0.05 + +BASE_TRIS_MIN = 1200 +BASE_TRIS_MAX = 2800 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -342,21 +379,31 @@

Source

MATERIAL_COUNT = 2 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 48 +COLLIDER_TRIS_MAX = 220 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 +METAL_FACES_MIN = 48 +WOOD_FACES_MIN = 200 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +SKID_Z_MAX = 1e-3 +HANDLE_JOIN = 0.008 +SLAT_JOIN = 0.006 WOOD_IDX = 0 METAL_IDX = 1 def eevee_engine_id(): - return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + return "BLENDER_EEVEE_NEXT" if bpy.app.version >= (4, 2, 0) else "BLENDER_EEVEE" def fail(msg, code): - print(f"ERROR: {msg}", file=sys.stderr) + print(f"FAIL[{code}]: {msg}", file=sys.stderr) return code @@ -366,18 +413,17 @@

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() + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() try: - eval_mesh.calc_loop_triangles() - return len(eval_mesh.loop_triangles) + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) finally: - eval_obj.to_mesh_clear() + ev.to_mesh_clear() -def add_cube(bm, loc, scale, mat_idx): +def add_box(bm, loc, scale, mat_idx): geo = bmesh.ops.create_cube(bm, size=1.0) verts = geo["verts"] for v in verts: @@ -390,6 +436,96 @@

Source

return verts +def _bridge_rings(bm, a, b, mat_idx): + segs = len(a) + for k in range(segs): + kn = (k + 1) % segs + face = bm.faces.new((a[k], a[kn], b[kn], b[k])) + face.material_index = mat_idx + + +def loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True): + verts = [v for ring in rings for v in ring] + n = len(rings) + for i in range(n - 1): + _bridge_rings(bm, rings[i], rings[i + 1], mat_idx) + if cap_start and len(rings[0]) > 1: + ring = rings[0] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[kn], ring[k])) + face.material_index = mat_idx + if cap_end and len(rings[-1]) > 1: + ring = rings[-1] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[k], ring[kn])) + face.material_index = mat_idx + return verts + + +def add_pipe_curve(bm, points, radius, segs, mat_idx): + rings = [] + n = len(points) + for i, p in enumerate(points): + p = Vector(p) + if i < n - 1: + tangent = (Vector(points[i + 1]) - p).normalized() + else: + tangent = (p - Vector(points[i - 1])).normalized() + side = Vector((-tangent.y, tangent.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = tangent.cross(side).normalized() + ring = [] + for k in range(segs): + a = 2.0 * math.pi * k / segs + ring.append( + bm.verts.new( + p + side * (radius * math.cos(a)) + up * (radius * math.sin(a)) + ) + ) + rings.append(ring) + return loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True) + + +def add_handle_bail(bm, x_in, x_out, y0, y1, z, radius, segs, mat_idx): + """U-bail with quarter-circle corners so the loft stays on the tube. + + A four-point polyline puts a 90-degree tangent jump at each outer + corner; the ring there orients to the next span and the bar sits on + the mounting plate instead of entering it. + """ + sign_x = 1.0 if x_out > x_in else -1.0 + sign_y = 1.0 if y1 > y0 else -1.0 + rad = min(0.014, abs(x_out - x_in) * 0.40, abs(y1 - y0) * 0.20) + pts = [ + Vector((x_in, y0, z)), + Vector((x_out - sign_x * rad, y0, z)), + ] + c1 = Vector((x_out - sign_x * rad, y0 + sign_y * rad, z)) + s1 = Vector((0.0, -sign_y * rad, 0.0)) + e1 = Vector((sign_x * rad, 0.0, 0.0)) + for i in range(1, 5): + a = (math.pi / 2.0) * i / 4.0 + pts.append(c1 + s1 * math.cos(a) + e1 * math.sin(a)) + pts.append(Vector((x_out, y1 - sign_y * rad, z))) + c2 = Vector((x_out - sign_x * rad, y1 - sign_y * rad, z)) + s2 = Vector((sign_x * rad, 0.0, 0.0)) + e2 = Vector((0.0, sign_y * rad, 0.0)) + for i in range(1, 5): + a = (math.pi / 2.0) * i / 4.0 + pts.append(c2 + s2 * math.cos(a) + e2 * math.sin(a)) + pts.append(Vector((x_in, y1, z))) + add_pipe_curve(bm, pts, radius, segs, mat_idx) + + def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) @@ -406,9 +542,7 @@

Source

col = i % cols row = i // cols nrm = face.normal - ax = abs(nrm.x) - ay = abs(nrm.y) - az = abs(nrm.z) + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) coords = [] for loop in face.loops: co = loop.vert.co @@ -433,109 +567,227 @@

Source

) -def build_crate_mesh(name, bevel_offset, bevel_segments): - sx, sy, sz = BODY_SIZE - slat_h = SLAT_THICK +def _span_layout(count, span, rng): + """Uneven slat widths that still fill `span` with named gaps.""" + raw = [1.0 + rng.uniform(-SLAT_JITTER, SLAT_JITTER) for _ in range(count)] + s = sum(raw) + gap = 0.010 + usable = span - gap * (count + 1) + widths = [usable * r / s for r in raw] + pos = -span / 2.0 + gap + centres = [] + for w in widths: + centres.append(pos + w / 2.0) + pos += w + gap + return centres, widths + + +def build_crate_mesh( + name, + short_skids=False, + float_handle=False, + omit_slats=False, +): + ix, iy, iz = INNER + hx = ix / 2.0 + POST / 2.0 + hy = iy / 2.0 + POST / 2.0 + post_h = iz + RAIL_H + top_z = SKID_H + post_h + rng = random.Random(SLAT_SEED) bm = bmesh.new() + wood_verts = [] try: - body = add_cube( - bm, (0.0, 0.0, sz / 2.0), (sx, sy, sz), WOOD_IDX, + skid_z0 = 0.055 if short_skids else 0.0 + if short_skids: + geo = bmesh.ops.create_cube(bm, size=1.0) + for v in geo["verts"]: + v.co.x *= 0.024 + v.co.y *= 0.024 + v.co.z *= 0.006 + v.co.z += 0.003 + for f in {f for v in geo["verts"] for f in v.link_faces}: + f.material_index = WOOD_IDX + # Skids sit under the posts, not inset at the inner wall line — + # that left a cave at the outer corner. + skid_ys = (-hy, 0.0, hy) + for sy in skid_ys: + wood_verts.extend( + add_box( + bm, + (0.0, sy, skid_z0 + SKID_H / 2.0), + (ix + 2.0 * POST, SKID_W, SKID_H), + WOOD_IDX, + ) + ) + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + wood_verts.extend( + add_box( + bm, + (sxn * hx, syn * hy, SKID_H - TENON + (post_h + TENON) / 2.0), + (POST, POST, post_h + TENON), + WOOD_IDX, + ) + ) + # Top rails tenon into the posts — overlapping, not sharing a plane. + wood_verts.extend( + add_box( + bm, (0.0, hy, top_z - RAIL_H / 2.0), + (ix + 2.0 * TENON, POST, RAIL_H), WOOD_IDX, + ) ) - body_edges = list({e for v in body for e in v.link_edges}) - if bevel_offset > 0.0: - bmesh.ops.bevel( - bm, - geom=body_edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, + wood_verts.extend( + add_box( + bm, (0.0, -hy, top_z - RAIL_H / 2.0), + (ix + 2.0 * TENON, POST, RAIL_H), WOOD_IDX, ) - - slat_w = 0.118 - n_lid = 5 - gap = (sy - 0.04 - n_lid * slat_w) / (n_lid + 1) - y0 = -sy / 2.0 + 0.02 + gap + slat_w / 2.0 - for i in range(n_lid): - y = y0 + i * (slat_w + gap) - add_cube( - bm, - (0.0, y, sz + slat_h / 2.0), - (sx - 0.06, slat_w, slat_h), - WOOD_IDX, + ) + wood_verts.extend( + add_box( + bm, (hx, 0.0, top_z - RAIL_H / 2.0), + (POST, iy + 2.0 * TENON, RAIL_H), WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, (-hx, 0.0, top_z - RAIL_H / 2.0), + (POST, iy + 2.0 * TENON, RAIL_H), WOOD_IDX, + ) + ) + # Bottom sill tenons into the skid so the shared z=SKID_H plane + # is not two coplanar faces (z-fight). + sill_h = RAIL_H + TENON + sill_z = SKID_H - TENON + sill_h / 2.0 + wood_verts.extend( + add_box( + bm, (0.0, hy, sill_z), + (ix + 2.0 * TENON, POST, sill_h), WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, (0.0, -hy, sill_z), + (ix + 2.0 * TENON, POST, sill_h), WOOD_IDX, ) + ) + wood_verts.extend( + add_box( + bm, (hx, 0.0, sill_z), + (POST, iy + 2.0 * TENON, sill_h), WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, (-hx, 0.0, sill_z), + (POST, iy + 2.0 * TENON, sill_h), WOOD_IDX, + ) + ) - n_side = 4 - side_h = 0.095 - z0 = 0.08 + side_h / 2.0 - z_step = (sz - 0.16 - side_h) / (n_side - 1) - for sign in (-1.0, 1.0): - for i in range(n_side): - z = z0 + i * z_step - add_cube( + floor_c, floor_w = _span_layout(N_FLOOR, ix, rng) + for c, w in zip(floor_c, floor_w): + wood_verts.extend( + add_box( bm, - (0.0, sign * (sy / 2.0 + slat_h / 2.0), z), - (sx - 0.10, slat_h, side_h), + (c, 0.0, SKID_H + SLAT_T / 2.0), + (w, iy + TENON, SLAT_T), WOOD_IDX, ) - add_cube( + ) + lid_c, lid_w = _span_layout(N_LID, ix + POST * 0.5, rng) + for c, w in zip(lid_c, lid_w): + wood_verts.extend( + add_box( bm, - (sign * (sx / 2.0 + slat_h / 2.0), 0.0, z), - (slat_h, sy - 0.10, side_h), + (c, 0.0, top_z + SLAT_T / 2.0), + (w, iy + POST, SLAT_T), WOOD_IDX, ) + ) - plate = 0.18 - thick = 0.028 - hx = sx / 2.0 + slat_h - hy = sy / 2.0 + slat_h - hz = sz - for sxn in (-1.0, 1.0): - for syn in (-1.0, 1.0): - for szt in (0.0, 1.0): - zc = thick / 2.0 if szt == 0.0 else hz - thick / 2.0 - add_cube( + if not omit_slats: + long_c, long_w = _span_layout(N_LONG, iz - 0.04, rng) + for sign in (-1.0, 1.0): + y = sign * (iy / 2.0 + POST - SLAT_T / 2.0 - 0.001) + for c, w in zip(long_c, long_w): + wood_verts.extend( + add_box( + bm, + (0.0, y, SKID_H + 0.02 + iz / 2.0 + c), + (ix + TENON, SLAT_T, w), + WOOD_IDX, + ) + ) + end_c, end_w = _span_layout(N_END, iz - 0.04, rng) + for sign in (-1.0, 1.0): + x = sign * (ix / 2.0 + POST - SLAT_T / 2.0 - 0.001) + for c, w in zip(end_c, end_w): + wood_verts.extend( + add_box( bm, - (sxn * (hx - plate / 2.0), syn * (hy - plate / 2.0), zc), - (plate, plate, thick), - METAL_IDX, + (x, 0.0, SKID_H + 0.02 + iz / 2.0 + c), + (SLAT_T, iy + TENON, w), + WOOD_IDX, ) - z_bar = hz / 2.0 - add_cube( + ) + + if wood_verts: + edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid}) + if edges: + bmesh.ops.bevel( bm, - (sxn * (hx - thick / 2.0), syn * (hy - plate / 2.0), z_bar), - (thick, plate, hz - 2.0 * thick), + geom=edges, + offset=0.002, + segments=1, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + # L-straps: two plates meeting at a vertical edge, proud of the + # post. Not three overlapping cubes — those leave window holes. + wrap = IRON_WRAP + strap_z0 = 0.006 + strap_h = top_z - strap_z0 + zc = strap_z0 + strap_h / 2.0 + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + ox = sxn * (hx + POST / 2.0 + IRON_T / 2.0) + oy = syn * (hy + POST / 2.0 + IRON_T / 2.0) + add_box( + bm, + (ox, syn * (hy + POST / 2.0 - wrap / 2.0 + IRON_T / 2.0), zc), + (IRON_T, wrap, strap_h), METAL_IDX, ) - add_cube( + add_box( bm, - (sxn * (hx - plate / 2.0), syn * (hy - thick / 2.0), z_bar), - (plate, thick, hz - 2.0 * thick), + (sxn * (hx + POST / 2.0 - wrap / 2.0 + IRON_T / 2.0), oy, zc), + (wrap - IRON_T, IRON_T, strap_h), METAL_IDX, ) - handle_out = 0.038 + hz = SKID_H + post_h * 0.52 for sxn in (-1.0, 1.0): - hx_bar = sxn * (hx + handle_out) - add_cube( - bm, - (hx_bar, -0.12, sz * 0.48), - (0.020, 0.020, 0.12), - METAL_IDX, - ) - add_cube( - bm, - (hx_bar, 0.12, sz * 0.48), - (0.020, 0.020, 0.12), - METAL_IDX, + x_plate = sxn * (ix / 2.0 + POST - SLAT_T / 2.0) + x_in = sxn * (ix / 2.0 + POST - SLAT_T - 0.008) + x_out = sxn * (ix / 2.0 + POST + HANDLE_OUT) + y0, y1 = -0.11, 0.11 + add_box( + bm, (x_plate, y0, hz), + (SLAT_T + IRON_T * 2.0, 0.034, 0.044), METAL_IDX, ) - add_cube( - bm, - (hx_bar, 0.0, sz * 0.54), - (0.020, 0.26, 0.020), - METAL_IDX, + add_box( + bm, (x_plate, y1, hz), + (SLAT_T + IRON_T * 2.0, 0.034, 0.044), METAL_IDX, ) + if float_handle: + add_box( + bm, (x_out, 0.0, hz), (0.020, 0.020, 0.020), METAL_IDX, + ) + else: + add_handle_bail( + bm, x_in, x_out, y0, y1, hz, HANDLE_R, 8, METAL_IDX, + ) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -556,29 +808,44 @@

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.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 def assign_slots(obj, wood, metal): - # Do not materials.clear() — that resets every polygon's material_index - # to 0 and the iron brackets vanish into wood. mats = obj.data.materials - if len(mats) == 0: - mats.append(wood) - mats.append(metal) - return - mats[0] = wood - if len(mats) == 1: - mats.append(metal) - else: - mats[1] = metal + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) def world_bbox(obj): @@ -614,6 +881,226 @@

Source

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): + vs = (me.vertices[idxs[i]].co, me.vertices[idxs[i + 1]].co) + area += (vs[0] - v0).cross(vs[1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + 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) + skids = [] + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if a[2] < 0.01 and dx > 0.6 and dy < 0.12 and dz < 0.08: + skids.append(a) + skid_z = min((a[2] for a in skids), default=99.0) + return {"skids": len(skids), "skid_z": skid_z} + + +def body_audit(me): + wood_ids = set() + for p in me.polygons: + if p.material_index == WOOD_IDX: + wood_ids.update(p.vertices) + pts = [ + me.vertices[i].co for i in wood_ids + if me.vertices[i].co.z > SKID_H + 0.01 + ] + if not pts: + return {"dx": 0.0, "dy": 0.0, "dz": 0.0} + xs, ys, zs = [p.x for p in pts], [p.y for p in pts], [p.z for p in pts] + return { + "dx": max(xs) - min(xs), + "dy": max(ys) - min(ys), + "dz": max(zs) - min(zs), + } + + +def _bvh_gap(me, hosts, guests): + if not hosts or not guests: + return 99.0 + bm_h = bmesh.new() + try: + bm_h.from_mesh(me) + keep = set() + for g in hosts: + keep.update(g) + drop = [ + f for f in bm_h.faces + if not all(v.index in keep for v in f.verts) + ] + if drop: + bmesh.ops.delete(bm_h, geom=drop, context="FACES") + if not bm_h.faces: + return 99.0 + tree = BVHTree.FromBMesh(bm_h) + worst = 0.0 + for g in guests: + bm_g = bmesh.new() + try: + bm_g.from_mesh(me) + member = set(g) + drop_g = [ + f for f in bm_g.faces + if not all(v.index in member for v in f.verts) + ] + if drop_g: + bmesh.ops.delete(bm_g, geom=drop_g, context="FACES") + if not bm_g.faces: + worst = max(worst, 99.0) + continue + tree_g = BVHTree.FromBMesh(bm_g) + if tree.overlap(tree_g): + continue + best = 99.0 + for i in g: + hit = tree.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + if best > worst: + worst = best + finally: + bm_g.free() + return worst + finally: + bm_h.free() + + +def handle_join(me): + groups = shells(me) + ends, handles = [], [] + for g in groups: + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if mat_of(me, g) == WOOD_IDX and dx < 0.04 and dy > 0.40 and 0.03 < dz < 0.18: + ends.append(g) + elif mat_of(me, g) == METAL_IDX and dz < 0.12 and max(dx, dy) > 0.15: + handles.append(g) + return _bvh_gap(me, ends, handles) + + +def slat_join(me): + groups = shells(me) + posts, slats = [], [] + for g in groups: + if mat_of(me, g) != WOOD_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.30 and dx < 0.08 and dy < 0.08: + posts.append(g) + elif 0.04 < dz < 0.16 and dx > 0.40 and dy < 0.030: + slats.append(g) + return _bvh_gap(me, posts, slats) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, 0.40)) + 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) @@ -627,7 +1114,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: @@ -649,13 +1135,12 @@

Source

return collider -def setup_bake_image(obj, wood, size=BAKE_RES): - # Adapted from snippets/setup_bake_target_image.py — do not replace slots. +def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None img = bpy.data.images.new("CrateNrm", size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" - nodes = wood.node_tree.nodes + nodes = target_mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") tex.image = img nodes.active = tex @@ -665,7 +1150,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" @@ -690,7 +1174,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: @@ -706,14 +1189,39 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_skids=False, + float_handle=False, + omit_slats=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_crate_mesh("CrateLow", bevel_offset=0.028, bevel_segments=2) - high = build_crate_mesh("CrateHigh", bevel_offset=0.028, bevel_segments=4) - wood = principled("CrateWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) - metal = principled("CrateMetal", (0.14, 0.13, 0.12, 1.0), 0.86, 0.36) + flags = dict( + short_skids=short_skids, + float_handle=float_handle, + omit_slats=omit_slats, + ) + low = build_crate_mesh("CrateLow", **flags) + high = build_crate_mesh("CrateHigh", **flags) + wood = principled( + "CrateWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50, + noise_scale=7.0, wear=(0.28, 0.14, 0.05, 1.0), + ) + metal = principled( + "CrateMetal", (0.18, 0.17, 0.16, 1.0), 0.86, 0.36, + noise_scale=5.0, wear=(0.10, 0.09, 0.08, 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("crate mesh did not build", 3), None, None, None, None, None @@ -745,7 +1253,9 @@

Source

r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 - collider = convex_hull_collider(low, "CrateCollider") + collider_src = build_crate_mesh("CrateColSrc", **flags) + collider = convex_hull_collider(collider_src, "CrateCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) export_path = os.path.join( @@ -757,9 +1267,14 @@

Source

export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + body = body_audit(low.data) + hj = handle_join(low.data) + sj = slat_join(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -776,6 +1291,16 @@

Source

f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" + ) + print( + f"measured supports skids={sup['skids']} skid_z={sup['skid_z']:.5f} " + f"body=({body['dx']:.4f},{body['dy']:.4f},{body['dz']:.4f}) " + f"handle_join={hj:.5f} slat_join={sj:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -792,6 +1317,11 @@

Source

f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", 5, ), None, None, None, None, None + 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 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})", @@ -835,6 +1365,44 @@

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 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, + ), None, None, None, None, None + if sup["skids"] < 3 or sup["skid_z"] > SKID_Z_MAX: + return fail( + f"skid supports {sup['skids']} skid_z={sup['skid_z']:.5f} " + "(--short-skids is the designed fail)", + 16, + ), None, None, None, None, None + if hj > HANDLE_JOIN: + return fail( + f"handle-end gap {hj:.5f} > {HANDLE_JOIN} " + "(--float-handle is the designed fail)", + 17, + ), None, None, None, None, None + if sj > SLAT_JOIN: + return fail( + f"slat-post gap {sj:.5f} > {SLAT_JOIN} " + "(--omit-slats is the designed fail)", + 18, + ), None, None, None, None, None + if abs(body["dx"] - BODY_X) > BODY_TOL: + return fail(f"body dx {body['dx']:.4f} off {BODY_X}", 19), None, None, None, None, None + if abs(body["dy"] - BODY_Y) > BODY_TOL: + return fail(f"body dy {body['dy']:.4f} off {BODY_Y}", 19), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -902,10 +1470,10 @@

Source

cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (2.25, -2.75, 1.38) + cam.location = (1.96, -2.44, 1.32) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.40) + aim.location = (0.0, 0.0, 0.30) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -934,7 +1502,6 @@

Source

fcode = gallery_framing.check_framing( scene, cam, hero=[low], elements=[low], stage=[floor, wall], - strategy="projection", ) if fcode: return fcode @@ -949,14 +1516,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("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--short-skids", action="store_true") + p.add_argument("--float-handle", action="store_true") + p.add_argument("--omit-slats", 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_skids=args.short_skids, + float_handle=args.float_handle, + omit_slats=args.omit_slats, + ) if code: return code if args.output: diff --git a/showcase/gallery.json b/showcase/gallery.json index 9a24950..93d22b7 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -9,7 +9,7 @@ "name": "shipping-crate", "dir": "showcase/shipping-crate", "teaches": "A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 624 tris, two materials with 132 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.352×0.856×0.748 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 36 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 2508 tris, two materials with 1170 wood and 280 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.146×0.726×0.612 m, grounded zmin, 3 skids, handle-end and slat-post join 0, mesh hygiene all zero, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 76 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-skids 16; --float-handle 17; --omit-slats 18.", "hero": "docs/gallery/assets/shipping-crate-hero.webp", "preview": "showcase/shipping-crate/preview.webp", "tags": [ @@ -285,7 +285,7 @@ "name": "iron-cauldron", "dir": "showcase/iron-cauldron", "teaches": "A procedural hanging iron cauldron with a bulged open pot, bail, and timber tripod through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 3388 tris, two materials with 450 wood and 1198 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.013×0.986×0.827 m, pot–leg clearance 0.051 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 98 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 3664 tris, two materials with 224 wood and 1768 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.987×0.961×0.860 m, pot–leg clearance 0.046 m, 3 coaxial ferrules at zmin 0, ferrule bite −0.00185 m, hook-bail join 0, mesh hygiene all zero, LOD ratios in band, convex collider 250 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-legs 16; --float-hook 17; --pipe-ferrule 18.", "hero": "docs/gallery/assets/iron-cauldron-hero.webp", "preview": "showcase/iron-cauldron/preview.webp", "tags": [ diff --git a/showcase/shipping-crate/README.md b/showcase/shipping-crate/README.md index e8506e5..5fc61ea 100644 --- a/showcase/shipping-crate/README.md +++ b/showcase/shipping-crate/README.md @@ -1,9 +1,14 @@ # Shipping crate -A showcase piece, not an example. Procedural crate (beveled body, arrayed -slats, iron corner brackets and end handles, two materials) 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 crate (skids under the +posts, corner posts with tenoned slats and seeded width jitter, bottom +sills, L-straps, filleted iron bail handles through mounting plates) +then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal +bake, LOD chain, convex collider, Unity glTF export. + +Skids sit under the posts so the outer corner is a mortise, not a cave. +Handle bails use quarter-circle corners so a 90-degree loft cannot sit +the bar on the plate. It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -21,37 +26,83 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 580–680 | 624 / 624 / 624 | -| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4455 | -| LOD2 ratio | 0.10–0.35 of base | 0.2179 / 0.2179 / 0.1571 | -| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 180 wood, 132 metal | +| Base triangles | 1200–2800 | 2508 / 2508 / 2508 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2057 | +| Materials | exactly 2 distinct; ≥200 wood, ≥48 metal faces | 2 slots; 1170 / 280 | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.352, 0.856, 0.748) m ± 0.01 | (1.3520, 0.8560, 0.7480), zmin 0 | -| Collider tris | ≤ 48 | 36 | -| Export | written, size > 0 | 50148 / 50148 / 50140 bytes | +| Outer AABB | (1.146, 0.726, 0.612) m ± 0.015 | (1.1460, 0.7260, 0.6120), zmin 0 | +| Collider tris | ≤ 220 | 76 | +| Export | written, size > 0 | 197408 / 197408 / 197392 bytes | + +Base triangles rose from **624 to 2508** in the quality pass: the old +beveled cube with glued-on slats became a post-and-slat crate with +tenons, sills, L-straps, and pipe handles. DECIMATE COLLAPSE triangle counts are **not** identical across series — -5.2.1 is more aggressive. The gate is a ratio band, not an exact count. +5.2.1 is leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is `has_data` plus operator -`FINISHED`, not byte-identity. Construction uses no RNG. +`FINISHED`, not byte-identity. Slat-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: 3 skids | each `zmin` ≤ 1e-3 | 3, skid_z 0.00000 | +| Body plan | 1.056 × 0.716 m ± 0.05 | 1.0560 × 0.7160 | -`--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) | +| --- | --- | --- | +| Handle-end BVH gap | ≤ 0.008 m | 0.00000 | +| Slat-post BVH gap | ≤ 0.006 m | 0.00000 | + +### Falsifiers + +Each violates one named budget. All seven 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-skids` | named skid supports at Z=0 | 16 | +| `--float-handle` | handle-end gap | 17 | +| `--omit-slats` | slat-post seat | 18 | ## Run ```bash blender --background --python shipping_crate.py -- blender --background --python shipping_crate.py -- --skip-decimate +blender --background --python shipping_crate.py -- --stray-vert +blender --background --python shipping_crate.py -- --lift-z +blender --background --python shipping_crate.py -- --short-skids +blender --background --python shipping_crate.py -- --float-handle +blender --background --python shipping_crate.py -- --omit-slats blender --background --python shipping_crate.py -- --output crate.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 | | --- | --- | @@ -60,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 ≠ 2 distinct slots, or 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 | @@ -70,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 skid floats | +| 17 | Joint fit: handle-end gap | +| 18 | Seat: slat-post gap | +| 19 | Body plan off the stated real-world size | diff --git a/showcase/shipping-crate/preview.webp b/showcase/shipping-crate/preview.webp index 3307c45feefac4f116b20d961a9d3410e19a35c2..379ecee81085edf8c7c4f4a23c93fa3a5de06e50 100644 GIT binary patch literal 19926 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 Z1A{pChQDa;F*XzIkN^M!n{2=U005<0dBp$# literal 11272 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 diff --git a/showcase/shipping-crate/shipping_crate.py b/showcase/shipping-crate/shipping_crate.py index b89fabf..89dc0bf 100644 --- a/showcase/shipping-crate/shipping_crate.py +++ b/showcase/shipping-crate/shipping_crate.py @@ -1,14 +1,23 @@ """Game-ready shipping crate — a showcase piece, not an example. -Asserts budget conformance of a procedural crate after composing shipped -pipeline pieces: bmesh construction, UVs, two materials, high-to-low -normal bake, LOD chain, convex collider, Unity glTF export. +Asserts budget conformance of a procedural crate (skids, corner posts, +tenoned slats with seeded width jitter, L-straps, iron bail handles) +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 solid beveled cube with identical slats glued on +and three overlapping cubes per corner that left window-holes through +the iron. 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-skids`` named skid +supports, ``--float-handle`` handle-to-end joint-fit, ``--omit-slats`` +slat-to-post seat. -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts +Fixed seed 17 for slat-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,6 +28,7 @@ import argparse import math import os +import random import sys import tempfile import traceback @@ -26,10 +36,8 @@ import bmesh import bpy 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,18 +45,33 @@ sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -# Body size (meters), sitting on z=0. Side slats sit proud on ±Y; lid -# slats sit proud on +Z. Iron handles sit proud on ±X. OUTER_SIZE is -# fitted after locking geometry against the measured world bbox. -BODY_SIZE = (1.20, 0.80, 0.72) -SLAT_THICK = 0.028 -OUTER_SIZE = (1.352, 0.856, 0.748) -BBOX_TOL = 0.01 - -# Measured after locking geometry. DECIMATE COLLAPSE ratios diverge -# across series — bands, not exact counts. -BASE_TRIS_MIN = 580 -BASE_TRIS_MAX = 680 +# A chest-sized shipping crate, ~96 × 62 × 58 cm inner, sitting on +# three skids. Outer AABB includes the proud L-straps and handles. +INNER = (0.96, 0.62, 0.52) +POST = 0.048 +SKID_H = 0.036 +SKID_W = 0.058 +SLAT_T = 0.016 +RAIL_H = 0.040 +TENON = 0.010 +IRON_T = 0.004 +IRON_WRAP = 0.072 +HANDLE_OUT = 0.038 +HANDLE_R = 0.007 +N_FLOOR = 6 +N_LID = 6 +N_LONG = 5 +N_END = 4 +SLAT_SEED = 17 +SLAT_JITTER = 0.045 + +BBOX_TOL = 0.015 +OUTER_SIZE = (1.146, 0.726, 0.612) +BODY_X, BODY_Y, BODY_Z = 1.056, 0.716, 0.576 +BODY_TOL = 0.05 + +BASE_TRIS_MIN = 1200 +BASE_TRIS_MAX = 2800 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -58,21 +81,31 @@ MATERIAL_COUNT = 2 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 48 +COLLIDER_TRIS_MAX = 220 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 +METAL_FACES_MIN = 48 +WOOD_FACES_MIN = 200 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +SKID_Z_MAX = 1e-3 +HANDLE_JOIN = 0.008 +SLAT_JOIN = 0.006 WOOD_IDX = 0 METAL_IDX = 1 def eevee_engine_id(): - return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + return "BLENDER_EEVEE_NEXT" if bpy.app.version >= (4, 2, 0) else "BLENDER_EEVEE" def fail(msg, code): - print(f"ERROR: {msg}", file=sys.stderr) + print(f"FAIL[{code}]: {msg}", file=sys.stderr) return code @@ -82,18 +115,17 @@ 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() + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() try: - eval_mesh.calc_loop_triangles() - return len(eval_mesh.loop_triangles) + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) finally: - eval_obj.to_mesh_clear() + ev.to_mesh_clear() -def add_cube(bm, loc, scale, mat_idx): +def add_box(bm, loc, scale, mat_idx): geo = bmesh.ops.create_cube(bm, size=1.0) verts = geo["verts"] for v in verts: @@ -106,6 +138,96 @@ def add_cube(bm, loc, scale, mat_idx): return verts +def _bridge_rings(bm, a, b, mat_idx): + segs = len(a) + for k in range(segs): + kn = (k + 1) % segs + face = bm.faces.new((a[k], a[kn], b[kn], b[k])) + face.material_index = mat_idx + + +def loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True): + verts = [v for ring in rings for v in ring] + n = len(rings) + for i in range(n - 1): + _bridge_rings(bm, rings[i], rings[i + 1], mat_idx) + if cap_start and len(rings[0]) > 1: + ring = rings[0] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[kn], ring[k])) + face.material_index = mat_idx + if cap_end and len(rings[-1]) > 1: + ring = rings[-1] + c = bm.verts.new(sum((v.co for v in ring), Vector((0, 0, 0))) / len(ring)) + verts.append(c) + for k in range(len(ring)): + kn = (k + 1) % len(ring) + face = bm.faces.new((c, ring[k], ring[kn])) + face.material_index = mat_idx + return verts + + +def add_pipe_curve(bm, points, radius, segs, mat_idx): + rings = [] + n = len(points) + for i, p in enumerate(points): + p = Vector(p) + if i < n - 1: + tangent = (Vector(points[i + 1]) - p).normalized() + else: + tangent = (p - Vector(points[i - 1])).normalized() + side = Vector((-tangent.y, tangent.x, 0.0)) + if side.length < 1e-6: + side = Vector((1.0, 0.0, 0.0)) + else: + side.normalize() + up = tangent.cross(side).normalized() + ring = [] + for k in range(segs): + a = 2.0 * math.pi * k / segs + ring.append( + bm.verts.new( + p + side * (radius * math.cos(a)) + up * (radius * math.sin(a)) + ) + ) + rings.append(ring) + return loft_rings(bm, rings, mat_idx, cap_start=True, cap_end=True) + + +def add_handle_bail(bm, x_in, x_out, y0, y1, z, radius, segs, mat_idx): + """U-bail with quarter-circle corners so the loft stays on the tube. + + A four-point polyline puts a 90-degree tangent jump at each outer + corner; the ring there orients to the next span and the bar sits on + the mounting plate instead of entering it. + """ + sign_x = 1.0 if x_out > x_in else -1.0 + sign_y = 1.0 if y1 > y0 else -1.0 + rad = min(0.014, abs(x_out - x_in) * 0.40, abs(y1 - y0) * 0.20) + pts = [ + Vector((x_in, y0, z)), + Vector((x_out - sign_x * rad, y0, z)), + ] + c1 = Vector((x_out - sign_x * rad, y0 + sign_y * rad, z)) + s1 = Vector((0.0, -sign_y * rad, 0.0)) + e1 = Vector((sign_x * rad, 0.0, 0.0)) + for i in range(1, 5): + a = (math.pi / 2.0) * i / 4.0 + pts.append(c1 + s1 * math.cos(a) + e1 * math.sin(a)) + pts.append(Vector((x_out, y1 - sign_y * rad, z))) + c2 = Vector((x_out - sign_x * rad, y1 - sign_y * rad, z)) + s2 = Vector((sign_x * rad, 0.0, 0.0)) + e2 = Vector((0.0, sign_y * rad, 0.0)) + for i in range(1, 5): + a = (math.pi / 2.0) * i / 4.0 + pts.append(c2 + s2 * math.cos(a) + e2 * math.sin(a)) + pts.append(Vector((x_in, y1, z))) + add_pipe_curve(bm, pts, radius, segs, mat_idx) + + def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) @@ -122,9 +244,7 @@ def pack_uvs(bm, margin=0.08): col = i % cols row = i // cols nrm = face.normal - ax = abs(nrm.x) - ay = abs(nrm.y) - az = abs(nrm.z) + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) coords = [] for loop in face.loops: co = loop.vert.co @@ -149,109 +269,227 @@ def pack_uvs(bm, margin=0.08): ) -def build_crate_mesh(name, bevel_offset, bevel_segments): - sx, sy, sz = BODY_SIZE - slat_h = SLAT_THICK +def _span_layout(count, span, rng): + """Uneven slat widths that still fill `span` with named gaps.""" + raw = [1.0 + rng.uniform(-SLAT_JITTER, SLAT_JITTER) for _ in range(count)] + s = sum(raw) + gap = 0.010 + usable = span - gap * (count + 1) + widths = [usable * r / s for r in raw] + pos = -span / 2.0 + gap + centres = [] + for w in widths: + centres.append(pos + w / 2.0) + pos += w + gap + return centres, widths + + +def build_crate_mesh( + name, + short_skids=False, + float_handle=False, + omit_slats=False, +): + ix, iy, iz = INNER + hx = ix / 2.0 + POST / 2.0 + hy = iy / 2.0 + POST / 2.0 + post_h = iz + RAIL_H + top_z = SKID_H + post_h + rng = random.Random(SLAT_SEED) bm = bmesh.new() + wood_verts = [] try: - body = add_cube( - bm, (0.0, 0.0, sz / 2.0), (sx, sy, sz), WOOD_IDX, + skid_z0 = 0.055 if short_skids else 0.0 + if short_skids: + geo = bmesh.ops.create_cube(bm, size=1.0) + for v in geo["verts"]: + v.co.x *= 0.024 + v.co.y *= 0.024 + v.co.z *= 0.006 + v.co.z += 0.003 + for f in {f for v in geo["verts"] for f in v.link_faces}: + f.material_index = WOOD_IDX + # Skids sit under the posts, not inset at the inner wall line — + # that left a cave at the outer corner. + skid_ys = (-hy, 0.0, hy) + for sy in skid_ys: + wood_verts.extend( + add_box( + bm, + (0.0, sy, skid_z0 + SKID_H / 2.0), + (ix + 2.0 * POST, SKID_W, SKID_H), + WOOD_IDX, + ) + ) + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + wood_verts.extend( + add_box( + bm, + (sxn * hx, syn * hy, SKID_H - TENON + (post_h + TENON) / 2.0), + (POST, POST, post_h + TENON), + WOOD_IDX, + ) + ) + # Top rails tenon into the posts — overlapping, not sharing a plane. + wood_verts.extend( + add_box( + bm, (0.0, hy, top_z - RAIL_H / 2.0), + (ix + 2.0 * TENON, POST, RAIL_H), WOOD_IDX, + ) ) - body_edges = list({e for v in body for e in v.link_edges}) - if bevel_offset > 0.0: - bmesh.ops.bevel( - bm, - geom=body_edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, + wood_verts.extend( + add_box( + bm, (0.0, -hy, top_z - RAIL_H / 2.0), + (ix + 2.0 * TENON, POST, RAIL_H), WOOD_IDX, ) - - slat_w = 0.118 - n_lid = 5 - gap = (sy - 0.04 - n_lid * slat_w) / (n_lid + 1) - y0 = -sy / 2.0 + 0.02 + gap + slat_w / 2.0 - for i in range(n_lid): - y = y0 + i * (slat_w + gap) - add_cube( - bm, - (0.0, y, sz + slat_h / 2.0), - (sx - 0.06, slat_w, slat_h), - WOOD_IDX, + ) + wood_verts.extend( + add_box( + bm, (hx, 0.0, top_z - RAIL_H / 2.0), + (POST, iy + 2.0 * TENON, RAIL_H), WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, (-hx, 0.0, top_z - RAIL_H / 2.0), + (POST, iy + 2.0 * TENON, RAIL_H), WOOD_IDX, ) + ) + # Bottom sill tenons into the skid so the shared z=SKID_H plane + # is not two coplanar faces (z-fight). + sill_h = RAIL_H + TENON + sill_z = SKID_H - TENON + sill_h / 2.0 + wood_verts.extend( + add_box( + bm, (0.0, hy, sill_z), + (ix + 2.0 * TENON, POST, sill_h), WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, (0.0, -hy, sill_z), + (ix + 2.0 * TENON, POST, sill_h), WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, (hx, 0.0, sill_z), + (POST, iy + 2.0 * TENON, sill_h), WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, (-hx, 0.0, sill_z), + (POST, iy + 2.0 * TENON, sill_h), WOOD_IDX, + ) + ) - n_side = 4 - side_h = 0.095 - z0 = 0.08 + side_h / 2.0 - z_step = (sz - 0.16 - side_h) / (n_side - 1) - for sign in (-1.0, 1.0): - for i in range(n_side): - z = z0 + i * z_step - add_cube( + floor_c, floor_w = _span_layout(N_FLOOR, ix, rng) + for c, w in zip(floor_c, floor_w): + wood_verts.extend( + add_box( bm, - (0.0, sign * (sy / 2.0 + slat_h / 2.0), z), - (sx - 0.10, slat_h, side_h), + (c, 0.0, SKID_H + SLAT_T / 2.0), + (w, iy + TENON, SLAT_T), WOOD_IDX, ) - add_cube( + ) + lid_c, lid_w = _span_layout(N_LID, ix + POST * 0.5, rng) + for c, w in zip(lid_c, lid_w): + wood_verts.extend( + add_box( bm, - (sign * (sx / 2.0 + slat_h / 2.0), 0.0, z), - (slat_h, sy - 0.10, side_h), + (c, 0.0, top_z + SLAT_T / 2.0), + (w, iy + POST, SLAT_T), WOOD_IDX, ) + ) - plate = 0.18 - thick = 0.028 - hx = sx / 2.0 + slat_h - hy = sy / 2.0 + slat_h - hz = sz - for sxn in (-1.0, 1.0): - for syn in (-1.0, 1.0): - for szt in (0.0, 1.0): - zc = thick / 2.0 if szt == 0.0 else hz - thick / 2.0 - add_cube( + if not omit_slats: + long_c, long_w = _span_layout(N_LONG, iz - 0.04, rng) + for sign in (-1.0, 1.0): + y = sign * (iy / 2.0 + POST - SLAT_T / 2.0 - 0.001) + for c, w in zip(long_c, long_w): + wood_verts.extend( + add_box( + bm, + (0.0, y, SKID_H + 0.02 + iz / 2.0 + c), + (ix + TENON, SLAT_T, w), + WOOD_IDX, + ) + ) + end_c, end_w = _span_layout(N_END, iz - 0.04, rng) + for sign in (-1.0, 1.0): + x = sign * (ix / 2.0 + POST - SLAT_T / 2.0 - 0.001) + for c, w in zip(end_c, end_w): + wood_verts.extend( + add_box( bm, - (sxn * (hx - plate / 2.0), syn * (hy - plate / 2.0), zc), - (plate, plate, thick), - METAL_IDX, + (x, 0.0, SKID_H + 0.02 + iz / 2.0 + c), + (SLAT_T, iy + TENON, w), + WOOD_IDX, ) - z_bar = hz / 2.0 - add_cube( + ) + + if wood_verts: + edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid}) + if edges: + bmesh.ops.bevel( + bm, + geom=edges, + offset=0.002, + segments=1, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + # L-straps: two plates meeting at a vertical edge, proud of the + # post. Not three overlapping cubes — those leave window holes. + wrap = IRON_WRAP + strap_z0 = 0.006 + strap_h = top_z - strap_z0 + zc = strap_z0 + strap_h / 2.0 + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + ox = sxn * (hx + POST / 2.0 + IRON_T / 2.0) + oy = syn * (hy + POST / 2.0 + IRON_T / 2.0) + add_box( bm, - (sxn * (hx - thick / 2.0), syn * (hy - plate / 2.0), z_bar), - (thick, plate, hz - 2.0 * thick), + (ox, syn * (hy + POST / 2.0 - wrap / 2.0 + IRON_T / 2.0), zc), + (IRON_T, wrap, strap_h), METAL_IDX, ) - add_cube( + add_box( bm, - (sxn * (hx - plate / 2.0), syn * (hy - thick / 2.0), z_bar), - (plate, thick, hz - 2.0 * thick), + (sxn * (hx + POST / 2.0 - wrap / 2.0 + IRON_T / 2.0), oy, zc), + (wrap - IRON_T, IRON_T, strap_h), METAL_IDX, ) - handle_out = 0.038 + hz = SKID_H + post_h * 0.52 for sxn in (-1.0, 1.0): - hx_bar = sxn * (hx + handle_out) - add_cube( - bm, - (hx_bar, -0.12, sz * 0.48), - (0.020, 0.020, 0.12), - METAL_IDX, - ) - add_cube( - bm, - (hx_bar, 0.12, sz * 0.48), - (0.020, 0.020, 0.12), - METAL_IDX, + x_plate = sxn * (ix / 2.0 + POST - SLAT_T / 2.0) + x_in = sxn * (ix / 2.0 + POST - SLAT_T - 0.008) + x_out = sxn * (ix / 2.0 + POST + HANDLE_OUT) + y0, y1 = -0.11, 0.11 + add_box( + bm, (x_plate, y0, hz), + (SLAT_T + IRON_T * 2.0, 0.034, 0.044), METAL_IDX, ) - add_cube( - bm, - (hx_bar, 0.0, sz * 0.54), - (0.020, 0.26, 0.020), - METAL_IDX, + add_box( + bm, (x_plate, y1, hz), + (SLAT_T + IRON_T * 2.0, 0.034, 0.044), METAL_IDX, ) + if float_handle: + add_box( + bm, (x_out, 0.0, hz), (0.020, 0.020, 0.020), METAL_IDX, + ) + else: + add_handle_bail( + bm, x_in, x_out, y0, y1, hz, HANDLE_R, 8, METAL_IDX, + ) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -272,29 +510,44 @@ def build_crate_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.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 def assign_slots(obj, wood, metal): - # Do not materials.clear() — that resets every polygon's material_index - # to 0 and the iron brackets vanish into wood. mats = obj.data.materials - if len(mats) == 0: - mats.append(wood) - mats.append(metal) - return - mats[0] = wood - if len(mats) == 1: - mats.append(metal) - else: - mats[1] = metal + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) def world_bbox(obj): @@ -330,6 +583,226 @@ def uv_stats(mesh): 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): + vs = (me.vertices[idxs[i]].co, me.vertices[idxs[i + 1]].co) + area += (vs[0] - v0).cross(vs[1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + 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) + skids = [] + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if a[2] < 0.01 and dx > 0.6 and dy < 0.12 and dz < 0.08: + skids.append(a) + skid_z = min((a[2] for a in skids), default=99.0) + return {"skids": len(skids), "skid_z": skid_z} + + +def body_audit(me): + wood_ids = set() + for p in me.polygons: + if p.material_index == WOOD_IDX: + wood_ids.update(p.vertices) + pts = [ + me.vertices[i].co for i in wood_ids + if me.vertices[i].co.z > SKID_H + 0.01 + ] + if not pts: + return {"dx": 0.0, "dy": 0.0, "dz": 0.0} + xs, ys, zs = [p.x for p in pts], [p.y for p in pts], [p.z for p in pts] + return { + "dx": max(xs) - min(xs), + "dy": max(ys) - min(ys), + "dz": max(zs) - min(zs), + } + + +def _bvh_gap(me, hosts, guests): + if not hosts or not guests: + return 99.0 + bm_h = bmesh.new() + try: + bm_h.from_mesh(me) + keep = set() + for g in hosts: + keep.update(g) + drop = [ + f for f in bm_h.faces + if not all(v.index in keep for v in f.verts) + ] + if drop: + bmesh.ops.delete(bm_h, geom=drop, context="FACES") + if not bm_h.faces: + return 99.0 + tree = BVHTree.FromBMesh(bm_h) + worst = 0.0 + for g in guests: + bm_g = bmesh.new() + try: + bm_g.from_mesh(me) + member = set(g) + drop_g = [ + f for f in bm_g.faces + if not all(v.index in member for v in f.verts) + ] + if drop_g: + bmesh.ops.delete(bm_g, geom=drop_g, context="FACES") + if not bm_g.faces: + worst = max(worst, 99.0) + continue + tree_g = BVHTree.FromBMesh(bm_g) + if tree.overlap(tree_g): + continue + best = 99.0 + for i in g: + hit = tree.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + if best > worst: + worst = best + finally: + bm_g.free() + return worst + finally: + bm_h.free() + + +def handle_join(me): + groups = shells(me) + ends, handles = [], [] + for g in groups: + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if mat_of(me, g) == WOOD_IDX and dx < 0.04 and dy > 0.40 and 0.03 < dz < 0.18: + ends.append(g) + elif mat_of(me, g) == METAL_IDX and dz < 0.12 and max(dx, dy) > 0.15: + handles.append(g) + return _bvh_gap(me, ends, handles) + + +def slat_join(me): + groups = shells(me) + posts, slats = [], [] + for g in groups: + if mat_of(me, g) != WOOD_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.30 and dx < 0.08 and dy < 0.08: + posts.append(g) + elif 0.04 < dz < 0.16 and dx > 0.40 and dy < 0.030: + slats.append(g) + return _bvh_gap(me, posts, slats) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, 0.40)) + 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) @@ -343,7 +816,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: @@ -365,13 +837,12 @@ def convex_hull_collider(obj, name): return collider -def setup_bake_image(obj, wood, size=BAKE_RES): - # Adapted from snippets/setup_bake_target_image.py — do not replace slots. +def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None img = bpy.data.images.new("CrateNrm", size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" - nodes = wood.node_tree.nodes + nodes = target_mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") tex.image = img nodes.active = tex @@ -381,7 +852,6 @@ def setup_bake_image(obj, wood, 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" @@ -406,7 +876,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: @@ -422,14 +891,39 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_skids=False, + float_handle=False, + omit_slats=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_crate_mesh("CrateLow", bevel_offset=0.028, bevel_segments=2) - high = build_crate_mesh("CrateHigh", bevel_offset=0.028, bevel_segments=4) - wood = principled("CrateWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) - metal = principled("CrateMetal", (0.14, 0.13, 0.12, 1.0), 0.86, 0.36) + flags = dict( + short_skids=short_skids, + float_handle=float_handle, + omit_slats=omit_slats, + ) + low = build_crate_mesh("CrateLow", **flags) + high = build_crate_mesh("CrateHigh", **flags) + wood = principled( + "CrateWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50, + noise_scale=7.0, wear=(0.28, 0.14, 0.05, 1.0), + ) + metal = principled( + "CrateMetal", (0.18, 0.17, 0.16, 1.0), 0.86, 0.36, + noise_scale=5.0, wear=(0.10, 0.09, 0.08, 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("crate mesh did not build", 3), None, None, None, None, None @@ -461,7 +955,9 @@ 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 = convex_hull_collider(low, "CrateCollider") + collider_src = build_crate_mesh("CrateColSrc", **flags) + collider = convex_hull_collider(collider_src, "CrateCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) export_path = os.path.join( @@ -473,9 +969,14 @@ def check(skip_decimate): export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + body = body_audit(low.data) + hj = handle_join(low.data) + sj = slat_join(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -492,6 +993,16 @@ def check(skip_decimate): f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" + ) + print( + f"measured supports skids={sup['skids']} skid_z={sup['skid_z']:.5f} " + f"body=({body['dx']:.4f},{body['dy']:.4f},{body['dz']:.4f}) " + f"handle_join={hj:.5f} slat_join={sj:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -508,6 +1019,11 @@ def check(skip_decimate): f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", 5, ), None, None, None, None, None + 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 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})", @@ -551,6 +1067,44 @@ 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 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, + ), None, None, None, None, None + if sup["skids"] < 3 or sup["skid_z"] > SKID_Z_MAX: + return fail( + f"skid supports {sup['skids']} skid_z={sup['skid_z']:.5f} " + "(--short-skids is the designed fail)", + 16, + ), None, None, None, None, None + if hj > HANDLE_JOIN: + return fail( + f"handle-end gap {hj:.5f} > {HANDLE_JOIN} " + "(--float-handle is the designed fail)", + 17, + ), None, None, None, None, None + if sj > SLAT_JOIN: + return fail( + f"slat-post gap {sj:.5f} > {SLAT_JOIN} " + "(--omit-slats is the designed fail)", + 18, + ), None, None, None, None, None + if abs(body["dx"] - BODY_X) > BODY_TOL: + return fail(f"body dx {body['dx']:.4f} off {BODY_X}", 19), None, None, None, None, None + if abs(body["dy"] - BODY_Y) > BODY_TOL: + return fail(f"body dy {body['dy']:.4f} off {BODY_Y}", 19), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -618,10 +1172,10 @@ def light(name, loc, energy, size, col, rot): cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (2.25, -2.75, 1.38) + cam.location = (1.96, -2.44, 1.32) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.40) + aim.location = (0.0, 0.0, 0.30) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -650,7 +1204,6 @@ def light(name, loc, energy, size, col, rot): fcode = gallery_framing.check_framing( scene, cam, hero=[low], elements=[low], stage=[floor, wall], - strategy="projection", ) if fcode: return fcode @@ -665,14 +1218,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("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--short-skids", action="store_true") + p.add_argument("--float-handle", action="store_true") + p.add_argument("--omit-slats", 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_skids=args.short_skids, + float_handle=args.float_handle, + omit_slats=args.omit_slats, + ) if code: return code if args.output: