diff --git a/docs/gallery/assets/iron-cauldron-hero.webp b/docs/gallery/assets/iron-cauldron-hero.webp index c455fc3..f981b30 100644 Binary files a/docs/gallery/assets/iron-cauldron-hero.webp and b/docs/gallery/assets/iron-cauldron-hero.webp differ diff --git a/docs/gallery/assets/shipping-crate-hero.webp b/docs/gallery/assets/shipping-crate-hero.webp index 588b8d6..abcf26f 100644 Binary files a/docs/gallery/assets/shipping-crate-hero.webp and b/docs/gallery/assets/shipping-crate-hero.webp differ 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/iron-cauldron/index.html b/docs/gallery/iron-cauldron/index.html index ea55dc7..c461b82 100644 --- a/docs/gallery/iron-cauldron/index.html +++ b/docs/gallery/iron-cauldron/index.html @@ -253,28 +253,42 @@

iron-cauldron

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/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/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 86c7d14..1ce8a2a 100644 Binary files a/showcase/iron-cauldron/preview.webp and b/showcase/iron-cauldron/preview.webp differ 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 3307c45..379ecee 100644 Binary files a/showcase/shipping-crate/preview.webp and b/showcase/shipping-crate/preview.webp differ 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: