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 @@
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 exampleA 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
Rendered headless by the example itself — click to zoom.
-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).
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.
+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 |
| 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.
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 |
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.
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. 15–19 are the hygiene and joint-fit family.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer / 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 |
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
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. 15–19 are the hygiene and joint-fit family.
+| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or a face-count floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named 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: