diff --git a/README.md b/README.md
index 4b93baa..4da406b 100644
--- a/README.md
+++ b/README.md
@@ -78,8 +78,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
-
-
+
+
[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9.
diff --git a/ROADMAP.md b/ROADMAP.md
index f6b0d95..814d086 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -109,8 +109,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Showcase sibling tree + shipping-crate pilot~~ **SHIPPED** as `showcase/shipping-crate/` — budget-conformance crate composing bake, LOD, collider, Unity glTF; `--skip-decimate` exits 9 on the LOD1 ratio band; DECIMATE COLLAPSE ratios diverge on 5.2 vs 4.5/5.1
- ~~Round stone well showcase~~ **SHIPPED** as `showcase/stone-well/` — running-bond brick well, shingled pyramid roof, windlass/rope/bucket; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Wooden barrel showcase~~ **SHIPPED** as `showcase/wooden-barrel/` — bulged staves, four iron hoops, lid heads; `--skip-decimate` exits 9 on the LOD1 ratio band
-- ~~Campfire showcase~~ **SHIPPED** as `showcase/campfire/` — cobble ring, ash mound, crossed logs; `--skip-decimate` exits 9 on the LOD1 ratio band
-- ~~Market stall showcase~~ **SHIPPED** as `showcase/market-stall/` — timber frame, striped awning, counter; `--skip-decimate` exits 9 on the LOD1 ratio band
+- ~~Campfire showcase~~ **SHIPPED** as `showcase/campfire/` — two-course fire ring, log tripod; `--skip-decimate` exits 9 on the LOD1 ratio band
+- ~~Market stall showcase~~ **SHIPPED** as `showcase/market-stall/` — timber frame, slatted counter, striped awning; `--skip-decimate` exits 9 on the LOD1 ratio band
- Procedural terrain or landscape showcase using Geometry Nodes scatter
- Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece
- Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)
diff --git a/docs/gallery/assets/campfire-hero.webp b/docs/gallery/assets/campfire-hero.webp
index b25c3b9..3cf3e73 100644
Binary files a/docs/gallery/assets/campfire-hero.webp and b/docs/gallery/assets/campfire-hero.webp differ
diff --git a/docs/gallery/assets/market-stall-hero.webp b/docs/gallery/assets/market-stall-hero.webp
index 46d7fad..4ab4f3d 100644
Binary files a/docs/gallery/assets/market-stall-hero.webp and b/docs/gallery/assets/market-stall-hero.webp differ
diff --git a/docs/gallery/campfire/index.html b/docs/gallery/campfire/index.html
index 3e729e5..e8303ae 100644
--- a/docs/gallery/campfire/index.html
+++ b/docs/gallery/campfire/index.html
@@ -253,18 +253,18 @@
Rendered headless by the example itself — click to zoom.
-
-A showcase piece, not an example. Procedural campfire (flattened cobble ring, inner stones, ash mound, crossed logs, sticks, charcoal) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+A showcase piece, not an example. Procedural campfire (two-course running-bond stone ring, pit-floor cobbles, ash, a log tripod, and charcoal) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
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 | 7400–7650 | 7528 / 7528 / 7528 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 | | Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 116 wood, 18 ash | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.964, 0.950, 0.252) m ± 0.01 | (0.9641, 0.9495, 0.2521), zmin 0 | | Collider tris | ≤ 160 | 105 | | Export | written, size > 0 | 548944 / 548944 / 548936 bytes |
+| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 3500–3750 | 3616 / 3616 / 3616 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | | Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 100 wood, 48 ash | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.789, 0.789, 0.410) m ± 0.01 | (0.7891, 0.7891, 0.4099), zmin 0 | | Collider tris | ≤ 320 | 254 | | Export | written, size > 0 | 273604 / 273604 / 273596 bytes |
DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.
--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.
Run
@@ -321,25 +321,28 @@ Source
sys.dont_write_bytecode = True
import gallery_framing # noqa: E402
-N_RING = 12
-RING_R = 0.40
-N_INNER = 7
-INNER_R = 0.22
-ASH_R = 0.26
-ASH_H = 0.055
-N_LOGS = 4
-LOG_LEN = 0.50
-LOG_R = 0.040
+N_AROUND = 12
+N_ROWS = 2
+R_INNER = 0.28
+STONE_D = 0.11
+R_OUTER = R_INNER + STONE_D
+R_MID = (R_INNER + R_OUTER) / 2.0
+WALL_H = 0.155
+STONE_H = WALL_H / N_ROWS
+N_FLOOR = 6
+N_ASH = 7
+N_LOGS = 2
+LOG_LEN = 0.34
+LOG_R = 0.032
N_STICKS = 3
-STICK_LEN = 0.38
-STICK_R = 0.016
+STICK_R = 0.030
N_COALS = 5
BBOX_TOL = 0.01
# Fitted to the generated AABB after locking geometry. Recomputed from bound_box.
-OUTER_SIZE = (0.964 , 0.950 , 0.252 )
+OUTER_SIZE = (0.789 , 0.789 , 0.410 )
-BASE_TRIS_MIN = 7400
-BASE_TRIS_MAX = 7650
+BASE_TRIS_MIN = 3500
+BASE_TRIS_MAX = 3750
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -349,7 +352,7 @@ Source
MATERIAL_COUNT = 3
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 160
+COLLIDER_TRIS_MAX = 320
BAKE_RES = 256
CAGE_EXTRUSION = 0.06
@@ -398,21 +401,26 @@ Source
return verts
-def add_cobble(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 ), lump=0.22 ):
- geo = bmesh.ops.create_icosphere(bm, subdivisions=1 , radius=0.5 )
- verts = list(geo["verts" ])
- rot = Euler(euler).to_matrix()
+def add_rock(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 ), lump=0.0 ):
+ # Bevelled boxes, same language as stone-well masonry — not icospheres.
+ # Subdiv-1 icos read as d20s at thumbnail regardless of smooth shading.
+ verts = add_box(bm, loc, scale, mat_idx, euler)
+ if lump <= 0.0 :
+ return verts
origin = Vector(loc)
+ rot = Euler(euler).to_matrix()
+ inv = rot.inverted()
for v in verts:
- p = Vector((v.co.x * scale[0 ], v.co.y * scale[1 ], v.co.z * scale[2 ]))
- h = math.sin(p.x * 19.7 + p.y * 13.3 ) * math.cos(p.z * 17.1 + p.x * 7.9 )
- h += 0.40 * math.sin(p.y * 31.0 + p.z * 23.0 )
- h = max(-0.80 , min(0.80 , h))
- p = p * (1.0 + lump * h)
- 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
+ local = inv @ (v.co - origin)
+ h = math.sin(local.x * 15.7 + local.y * 11.3 + local.z * 9.1 )
+ h += 0.35 * math.cos(local.y * 21.0 + local.x * 8.0 )
+ h = max(-0.85 , min(0.85 , h))
+ local = Vector((
+ local.x * (1.0 + lump * h),
+ local.y * (1.0 + lump * 0.65 * math.sin(local.z * 13.0 + 1.2 )),
+ local.z * (1.0 + lump * 0.40 * h),
+ ))
+ v.co = rot @ local + origin
return verts
@@ -484,151 +492,159 @@ Source
)
-def _stone_size(i, base_xy, base_z, k):
- sx = base_xy[0 ] + 0.035 * abs(math.sin(i * 2.17 + k))
- sy = base_xy[1 ] + 0.028 * abs(math.cos(i * 1.73 + k * 0.7 ))
- sz = base_z + 0.038 * abs(math.sin(i * 1.11 + k * 1.3 ))
- return sx, sy, sz
-
-
def build_campfire_mesh(name, bevel_offset, bevel_segments):
bm = bmesh.new()
- bevel_verts = []
- ash_faces = set()
- wood_faces = set()
try :
- for i in range(N_RING):
- ang = 2.0 * math.pi * i / N_RING
- sx, sy, sz = _stone_size(i, (0.175 , 0.138 ), 0.070 , 0.2 )
- r = RING_R + 0.028 * math.sin(i * 1.9 )
- loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0 )
- tilt = (
- math.radians(18.0 * math.sin(i * 0.7 )),
- math.radians(14.0 * math.cos(i * 1.3 )),
- ang + math.radians(22.0 * math.sin(i * 1.1 )),
- )
- bevel_verts.extend(
- add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.24 )
+ # Two-course running-bond ring — same masonry as stone-well, not a
+ # circle of cubes or icos. Bevel stone edges only; logs go on after.
+ stone_verts = []
+ stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88
+ actual_h = STONE_H * 0.90
+ for row in range(N_ROWS):
+ z = actual_h / 2.0 + row * STONE_H
+ rot_off = (row % 2 ) * (math.pi / N_AROUND)
+ for i in range(N_AROUND):
+ ang = 2.0 * math.pi * i / N_AROUND + rot_off
+ loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z)
+ stone_verts.extend(
+ add_box(
+ bm,
+ loc,
+ (STONE_D, stone_w, actual_h),
+ STONE_IDX,
+ euler=(0.0 , 0.0 , ang),
+ )
+ )
+
+ for i in range(N_FLOOR):
+ ang = 2.0 * math.pi * i / N_FLOOR + 0.22
+ r = 0.11 + 0.02 * (i % 2 )
+ sz = 0.032 + 0.008 * (i % 3 )
+ loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50 )
+ stone_verts.extend(
+ add_box(
+ bm,
+ loc,
+ (0.078 , 0.058 , sz),
+ STONE_IDX,
+ euler=(0.0 , 0.0 , ang),
+ )
)
- for i in range(N_INNER):
- ang = 2.0 * math.pi * i / N_INNER + 0.31
- sx, sy, sz = _stone_size(i, (0.100 , 0.082 ), 0.048 , 1.4 )
- r = INNER_R + 0.018 * math.cos(i * 1.4 )
- loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0 )
- tilt = (
- math.radians(12.0 * math.cos(i * 0.9 )),
- math.radians(10.0 * math.sin(i * 1.5 )),
- ang + 0.4 ,
- )
- bevel_verts.extend(
- add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.20 )
+ if bevel_offset > 0.0 :
+ edges = list({e for v in stone_verts for e in v.link_edges})
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
)
- before = set(bm.faces)
- add_cone(
+ add_rock(
bm,
- (0.0 , 0.0 , ASH_H / 2.0 ),
- ASH_R,
- 0.055 ,
- ASH_H,
- 16 ,
+ (0.0 , 0.0 , 0.014 ),
+ (0.10 , 0.09 , 0.024 ),
ASH_IDX,
+ lump=0.14 ,
)
- ash_faces.update(set(bm.faces) - before)
+ for i in range(N_ASH):
+ ang = 2.0 * math.pi * i / N_ASH + 0.18
+ r = 0.022 + 0.040 * ((i % 4 ) / 3.0 )
+ sz = 0.016 + 0.010 * abs(math.sin(i * 1.7 ))
+ loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50 )
+ add_rock(
+ bm,
+ loc,
+ (0.032 + 0.006 * (i % 3 ), 0.026 , sz),
+ ASH_IDX,
+ euler=(0.0 , 0.0 , ang),
+ lump=0.16 ,
+ )
for i in range(N_LOGS):
yaw = i * (math.pi / 2.0 ) + math.radians(18.0 )
loc = (
- 0.045 * math.cos(yaw + math.pi / 4.0 ),
- 0.045 * math.sin(yaw + math.pi / 4.0 ),
- LOG_R + 0.020 + 0.012 * (i % 2 ),
+ 0.04 * math.cos(yaw + math.pi / 2.0 ),
+ 0.04 * math.sin(yaw + math.pi / 2.0 ),
+ LOG_R + 0.018 + 0.012 * i,
)
- before = set(bm.faces)
- verts = add_cylinder(
+ add_cone(
bm,
loc,
- LOG_R * (1.0 - 0.08 * (i % 2 )),
+ LOG_R * (1.0 - 0.04 * i),
+ LOG_R * 0.88 ,
LOG_LEN,
12 ,
WOOD_IDX,
euler=(math.pi / 2.0 , 0.0 , yaw),
)
- bevel_verts.extend(verts)
- wood_faces.update(set(bm.faces) - before)
+ # Teepee aimed at a shared apex so the sticks read as one fire, not
+ # four independent posts. Cone local +Z tracks base -> apex.
+ z_apex = WALL_H + 0.24
+ z_base = 0.040
+ r_base = R_INNER - 0.05
for i in range(N_STICKS):
- yaw = i * (2.0 * math.pi / N_STICKS) + 0.55
- pitch = math.radians(32.0 + 6.0 * math.sin(i * 1.7 ))
- loc = (
- 0.07 * math.cos(yaw),
- 0.07 * math.sin(yaw),
- 0.055 + STICK_R,
- )
- before = set(bm.faces)
- verts = add_cylinder(
+ yaw = i * (2.0 * math.pi / N_STICKS) + math.pi / 2.0
+ base = Vector((
+ r_base * math.cos(yaw),
+ r_base * math.sin(yaw),
+ z_base,
+ ))
+ apex = Vector((0.0 , 0.0 , z_apex))
+ delta = apex - base
+ mid = (base + apex) * 0.5
+ rot = delta.to_track_quat("Z" , "Y" ).to_euler()
+ add_cone(
bm,
- loc,
+ (mid.x, mid.y, mid.z),
STICK_R,
- STICK_LEN,
- 8 ,
+ STICK_R * 0.88 ,
+ delta.length,
+ 12 ,
WOOD_IDX,
- euler=(math.pi / 2.0 - pitch, 0.0 , yaw),
+ euler=(rot.x, rot.y, rot.z),
)
- bevel_verts.extend(verts)
- wood_faces.update(set(bm.faces) - before)
for i in range(N_COALS):
- ang = 2.0 * math.pi * i / N_COALS + 0.2
- r = 0.07 + 0.025 * (i % 2 )
- sz = 0.028 + 0.010 * abs(math.sin(i * 2.2 ))
- loc = (r * math.cos(ang), r * math.sin(ang), ASH_H * 0.55 + sz / 2.0 )
- before = set(bm.faces)
- verts = add_box(
+ ang = 2.0 * math.pi * i / N_COALS + 0.11
+ r = 0.030 + 0.022 * (i % 3 )
+ sz = 0.014 + 0.008 * abs(math.sin(i * 2.2 ))
+ loc = (r * math.cos(ang), r * math.sin(ang), 0.022 + sz / 2.0 )
+ add_rock(
bm,
loc,
- (0.040 , 0.028 , sz),
+ (0.026 , 0.020 , sz),
WOOD_IDX,
euler=(0.0 , 0.0 , ang),
- )
- bevel_verts.extend(verts)
- wood_faces.update(set(bm.faces) - before)
-
- if bevel_offset > 0.0 :
- edges = list({e for v in bevel_verts for e in v.link_edges})
- bmesh.ops.bevel(
- bm,
- geom=edges,
- offset=bevel_offset,
- segments=bevel_segments,
- profile=0.5 ,
- affect="EDGES" ,
- clamp_overlap=True ,
+ lump=0.18 ,
)
- zmin = min(v.co.z for v in bm.verts)
- if zmin != 0.0 :
- for v in bm.verts:
- v.co.z -= zmin
+ for v in bm.verts:
+ if v.co.z < 0.0 :
+ v.co.z = 0.0
pack_uvs(bm)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
for face in bm.faces:
- face.smooth = True
+ face.smooth = face.material_index == WOOD_IDX
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(35.0 ):
- edge.smooth = False
- for f in ash_faces:
- if f.is_valid:
- f.material_index = ASH_IDX
- for f in wood_faces:
- if f.is_valid:
- f.material_index = WOOD_IDX
+ mats = {f.material_index for f in edge.link_faces}
+ if WOOD_IDX in mats and STONE_IDX not in mats and ASH_IDX not in mats:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2 :
+ if edge.calc_face_angle() > math.radians(38.0 ):
+ edge.smooth = False
+ else :
+ edge.smooth = False
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
me.update()
+ for poly in me.polygons:
+ poly.use_smooth = poly.material_index == WOOD_IDX
finally :
bm.free()
obj = bpy.data.objects.new(name, me)
@@ -785,11 +801,11 @@ Source
def check(skip_decimate):
bpy.ops.wm.read_factory_settings(use_empty=True )
- low = build_campfire_mesh("CampfireLow" , bevel_offset=0.006 , bevel_segments=2 )
- high = build_campfire_mesh("CampfireHigh" , bevel_offset=0.006 , bevel_segments=4 )
- stone = principled("CampfireStone" , (0.46 , 0.40 , 0.34 , 1.0 ), 0.0 , 0.72 )
- wood = principled("CampfireWood" , (0.14 , 0.075 , 0.040 , 1.0 ), 0.0 , 0.78 )
- ash = principled("CampfireAsh" , (0.28 , 0.26 , 0.24 , 1.0 ), 0.0 , 0.90 )
+ low = build_campfire_mesh("CampfireLow" , bevel_offset=0.010 , bevel_segments=2 )
+ high = build_campfire_mesh("CampfireHigh" , bevel_offset=0.010 , bevel_segments=4 )
+ stone = principled("CampfireStone" , (0.40 , 0.42 , 0.46 , 1.0 ), 0.0 , 0.84 )
+ wood = principled("CampfireWood" , (0.48 , 0.22 , 0.07 , 1.0 ), 0.0 , 0.50 )
+ ash = principled("CampfireAsh" , (0.12 , 0.11 , 0.10 , 1.0 ), 0.0 , 0.94 )
assign_slots(low, stone, wood, ash)
assign_slots(high, stone, wood, ash)
@@ -942,8 +958,8 @@ Source
ob.hide_render = True
ob.hide_viewport = True
- low.rotation_euler.z = math.radians(-36.0 )
- low.rotation_euler.x = math.radians(4.0 )
+ low.rotation_euler.z = math.radians(-28.0 )
+ low.rotation_euler.x = math.radians(0.0 )
floor_me = bpy.data.meshes.new("Floor" )
bm = bmesh.new()
@@ -989,10 +1005,10 @@ Source
cam_data = bpy.data.cameras.new("Cam" )
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam" , cam_data)
- cam.location = (0.96 , -1.33 , 0.69 )
+ cam.location = (0.99 , -1.42 , 0.93 )
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim" , None )
- aim.location = (0.0 , 0.0 , 0.10 )
+ aim.location = (0.0 , 0.0 , 0.16 )
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO" )
con.target = aim
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index 4c44b92..92694c5 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -925,7 +925,7 @@
A procedural campfire through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
-
witnesses Recomputed: 7528 tris, three materials with 116 wood and 18 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.964×0.950×0.252 m, LOD ratios in band, convex collider 105 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 3616 tris, three materials with 100 wood and 48 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.789×0.789×0.410 m, LOD ratios in band, convex collider 254 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
View example →
@@ -936,7 +936,7 @@
A procedural timber market stall through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
-
witnesses Recomputed: 2244 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.360×0.964×1.743 m, LOD ratios in band, convex collider 28 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 4404 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.392×0.966×1.743 m, LOD ratios in band, convex collider 46 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
View example →
diff --git a/docs/gallery/market-stall/index.html b/docs/gallery/market-stall/index.html
index 0d54ced..b7bcce6 100644
--- a/docs/gallery/market-stall/index.html
+++ b/docs/gallery/market-stall/index.html
@@ -253,18 +253,18 @@ market-stall
Rendered headless by the example itself — click to zoom.
- witnesses Recomputed: 2244 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.360×0.964×1.743 m, LOD ratios in band, convex collider 28 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+ witnesses Recomputed: 4404 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.392×0.966×1.743 m, LOD ratios in band, convex collider 46 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
blender --background --python showcase/market-stall/market_stall.py --
Copy
-A showcase piece, not an example. Procedural timber stall (posts, slanted side plates, counter with apron and shelf, backboard, striped awning and valance) 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 timber stall (posts with feet, slanted rafters, slatted counter and shelf, back-wall planks, eave fascia, striped awning and valance) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
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 | 2140–2350 | 2244 / 2244 / 2244 | | 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.2193 | | Materials | exactly 3 distinct, ≥16 faces per stripe slot | 3 slots, 48 / 48 stripe faces | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.360, 0.964, 1.743) m ± 0.01 | (1.3600, 0.9639, 1.7430), zmin 0 | | Collider tris | ≤ 80 | 28 | | Export | written, size > 0 | 163992 / 163992 / 163968 bytes |
+| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 4280–4550 | 4404 / 4404 / 4404 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 3 distinct, ≥16 faces per stripe slot | 3 slots, 48 / 48 stripe faces | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.392, 0.966, 1.743) m ± 0.01 | (1.3915, 0.9659, 1.7430), zmin 0 | | Collider tris | ≤ 80 | 46 | | Export | written, size > 0 | 316748 / 316748 / 316724 bytes |
DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.
--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.
Run
@@ -334,10 +334,10 @@ Source
BRACE = 0.036
BBOX_TOL = 0.01
# Fitted to the generated AABB after locking geometry. Recomputed from bound_box.
-OUTER_SIZE = (1.360 , 0.964 , 1.743 )
+OUTER_SIZE = (1.392 , 0.966 , 1.743 )
-BASE_TRIS_MIN = 2140
-BASE_TRIS_MAX = 2350
+BASE_TRIS_MIN = 4280
+BASE_TRIS_MAX = 4550
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -396,6 +396,27 @@ Source
return verts
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True ,
+ cap_tris=False ,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = geo["verts" ]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
def pack_uvs(bm, margin=0.08 ):
uv = bm.loops.layers.uv.new("UVMap" )
faces = list(bm.faces)
@@ -456,6 +477,9 @@ Source
)
for x, y, z, h in posts:
wood_verts.extend(add_box(bm, (x, y, z), (POST, POST, h), WOOD_IDX))
+ wood_verts.extend(
+ add_box(bm, (x, y, 0.018 ), (POST * 1.45 , POST * 1.45 , 0.036 ), WOOD_IDX)
+ )
# Front and back header beams (along X).
wood_verts.extend(
@@ -488,6 +512,18 @@ Source
euler=(pitch, 0.0 , 0.0 ),
)
)
+ # Inner rafters under the awning — the empty underside was a slab of air.
+ for i in range(3 ):
+ rx = -hx + POST * 2.0 + (i + 1 ) * (WIDTH - 4.0 * POST) / 4.0
+ wood_verts.extend(
+ add_box(
+ bm,
+ (rx, 0.0 , (FRONT_H + BACK_H) / 2.0 - POST * 0.42 ),
+ (POST * 0.42 , slant_len * 0.96 , POST * 0.42 ),
+ WOOD_IDX,
+ euler=(pitch, 0.0 , 0.0 ),
+ )
+ )
# Mid rail on the back wall and two diagonal braces at the sides.
wood_verts.extend(
@@ -515,33 +551,44 @@ Source
)
)
- # Counter top, apron, and four legs.
+ # Slatted counter — crate-lid language, not one fat slab.
cy = -hy + COUNTER_D / 2.0 + 0.04
- wood_verts.extend(
- add_box(
- bm,
- (0.0 , cy, COUNTER_Z),
- (WIDTH - 0.04 , COUNTER_D, COUNTER_T),
- WOOD_IDX,
+ n_slats = 6
+ slat_gap = 0.010
+ slat_d = (COUNTER_D - 0.02 - (n_slats - 1 ) * slat_gap) / n_slats
+ y0 = cy - COUNTER_D / 2.0 + 0.01 + slat_d / 2.0
+ for i in range(n_slats):
+ wood_verts.extend(
+ add_box(
+ bm,
+ (0.0 , y0 + i * (slat_d + slat_gap), COUNTER_Z),
+ (WIDTH - 0.06 , slat_d, COUNTER_T * 0.72 ),
+ WOOD_IDX,
+ )
)
- )
wood_verts.extend(
add_box(
bm,
- (0.0 , cy - COUNTER_D / 2.0 + COUNTER_T / 2.0 , COUNTER_Z - 0.09 ),
- (WIDTH - 0.08 , COUNTER_T * 0.7 , 0.16 ),
+ (0.0 , cy - COUNTER_D / 2.0 + 0.018 , COUNTER_Z - 0.09 ),
+ (WIDTH - 0.08 , 0.036 , 0.16 ),
WOOD_IDX,
)
)
under_shelf_z = 0.38
- wood_verts.extend(
- add_box(
- bm,
- (0.0 , cy, under_shelf_z),
- (WIDTH - 0.16 , COUNTER_D * 0.78 , 0.028 ),
- WOOD_IDX,
+ n_shelf = 4
+ shelf_d = COUNTER_D * 0.78
+ shelf_gap = 0.010
+ shelf_slat = (shelf_d - (n_shelf - 1 ) * shelf_gap) / n_shelf
+ sy0 = cy - shelf_d / 2.0 + shelf_slat / 2.0
+ for i in range(n_shelf):
+ wood_verts.extend(
+ add_box(
+ bm,
+ (0.0 , sy0 + i * (shelf_slat + shelf_gap), under_shelf_z),
+ (WIDTH - 0.16 , shelf_slat, 0.024 ),
+ WOOD_IDX,
+ )
)
- )
for lx in (-hx + 0.16 , hx - 0.16 ):
for ly in (cy - COUNTER_D * 0.32 , cy + COUNTER_D * 0.32 ):
wood_verts.extend(
@@ -553,12 +600,24 @@ Source
)
)
- # Backboard behind the counter.
+ # Back wall: horizontal planks with gaps, not a felt slab.
+ for i in range(5 ):
+ z = 0.36 + i * 0.155
+ wood_verts.extend(
+ add_box(
+ bm,
+ (0.0 , hy - inset - 0.014 , z),
+ (WIDTH - 2.35 * POST, 0.028 , 0.122 ),
+ WOOD_IDX,
+ )
+ )
+
+ # Front eave fascia under the valance.
wood_verts.extend(
add_box(
bm,
- (0.0 , hy - inset - 0.012 , 0.95 ),
- (WIDTH - 2.2 * POST, 0.022 , 0.55 ),
+ (0.0 , -hy - 0.008 , FRONT_H - 0.028 ),
+ (WIDTH - POST * 0.4 , 0.032 , 0.048 ),
WOOD_IDX,
)
)
@@ -947,8 +1006,8 @@ Source
ob.hide_render = True
ob.hide_viewport = True
- low.rotation_euler.z = math.radians(-26.0 )
- low.rotation_euler.x = math.radians(3.0 )
+ low.rotation_euler.z = math.radians(-18.0 )
+ low.rotation_euler.x = math.radians(2.0 )
floor_me = bpy.data.meshes.new("Floor" )
bm = bmesh.new()
@@ -994,10 +1053,10 @@ Source
cam_data = bpy.data.cameras.new("Cam" )
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam" , cam_data)
- cam.location = (3.51 , -5.00 , 2.42 )
+ cam.location = (3.11 , -5.10 , 1.60 )
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim" , None )
- aim.location = (0.0 , 0.0 , 0.88 )
+ aim.location = (0.0 , -0.10 , 0.92 )
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO" )
con.target = aim
diff --git a/showcase/campfire/README.md b/showcase/campfire/README.md
index a92161a..67e304f 100644
--- a/showcase/campfire/README.md
+++ b/showcase/campfire/README.md
@@ -1,9 +1,9 @@
# Campfire
-A showcase piece, not an example. Procedural campfire (flattened cobble
-ring, inner stones, ash mound, crossed logs, sticks, charcoal) then the
-shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD
-chain, convex collider, Unity glTF export.
+A showcase piece, not an example. Procedural campfire (two-course
+running-bond stone ring, pit-floor cobbles, ash, a log tripod, and
+charcoal) then the shipped pipeline: unique-cell UVs, Cycles
+high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
It asserts **budget conformance** of the generated result. It does not
witness an API contract. "It rendered without error" is not a check.
@@ -21,14 +21,14 @@ materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
-| Base triangles | 7400–7650 | 7528 / 7528 / 7528 |
+| Base triangles | 3500–3750 | 3616 / 3616 / 3616 |
| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
-| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 |
-| Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 116 wood, 18 ash |
+| LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 |
+| Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 100 wood, 48 ash |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
-| Outer AABB | (0.964, 0.950, 0.252) m ± 0.01 | (0.9641, 0.9495, 0.2521), zmin 0 |
-| Collider tris | ≤ 160 | 105 |
-| Export | written, size > 0 | 548944 / 548944 / 548936 bytes |
+| Outer AABB | (0.789, 0.789, 0.410) m ± 0.01 | (0.7891, 0.7891, 0.4094), zmin 0 |
+| Collider tris | ≤ 320 | 254 |
+| Export | written, size > 0 | 273604 / 273604 / 273596 bytes |
DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across
series — the gate is a ratio band, not an exact count. This mesh happened
diff --git a/showcase/campfire/campfire.py b/showcase/campfire/campfire.py
index d8f2a82..d332045 100644
--- a/showcase/campfire/campfire.py
+++ b/showcase/campfire/campfire.py
@@ -37,25 +37,28 @@
sys.dont_write_bytecode = True
import gallery_framing # noqa: E402
-N_RING = 12
-RING_R = 0.40
-N_INNER = 7
-INNER_R = 0.22
-ASH_R = 0.26
-ASH_H = 0.055
-N_LOGS = 4
-LOG_LEN = 0.50
-LOG_R = 0.040
+N_AROUND = 12
+N_ROWS = 2
+R_INNER = 0.28
+STONE_D = 0.11
+R_OUTER = R_INNER + STONE_D
+R_MID = (R_INNER + R_OUTER) / 2.0
+WALL_H = 0.155
+STONE_H = WALL_H / N_ROWS
+N_FLOOR = 6
+N_ASH = 7
+N_LOGS = 2
+LOG_LEN = 0.34
+LOG_R = 0.032
N_STICKS = 3
-STICK_LEN = 0.38
-STICK_R = 0.016
+STICK_R = 0.030
N_COALS = 5
BBOX_TOL = 0.01
# Fitted to the generated AABB after locking geometry. Recomputed from bound_box.
-OUTER_SIZE = (0.964, 0.950, 0.252)
+OUTER_SIZE = (0.789, 0.789, 0.410)
-BASE_TRIS_MIN = 7400
-BASE_TRIS_MAX = 7650
+BASE_TRIS_MIN = 3500
+BASE_TRIS_MAX = 3750
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -65,7 +68,7 @@
MATERIAL_COUNT = 3
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 160
+COLLIDER_TRIS_MAX = 320
BAKE_RES = 256
CAGE_EXTRUSION = 0.06
@@ -114,21 +117,26 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
return verts
-def add_cobble(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0), lump=0.22):
- geo = bmesh.ops.create_icosphere(bm, subdivisions=1, radius=0.5)
- verts = list(geo["verts"])
- rot = Euler(euler).to_matrix()
+def add_rock(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0), lump=0.0):
+ # Bevelled boxes, same language as stone-well masonry — not icospheres.
+ # Subdiv-1 icos read as d20s at thumbnail regardless of smooth shading.
+ verts = add_box(bm, loc, scale, mat_idx, euler)
+ if lump <= 0.0:
+ return verts
origin = Vector(loc)
+ rot = Euler(euler).to_matrix()
+ inv = rot.inverted()
for v in verts:
- p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
- h = math.sin(p.x * 19.7 + p.y * 13.3) * math.cos(p.z * 17.1 + p.x * 7.9)
- h += 0.40 * math.sin(p.y * 31.0 + p.z * 23.0)
- h = max(-0.80, min(0.80, h))
- p = p * (1.0 + lump * h)
- 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
+ local = inv @ (v.co - origin)
+ h = math.sin(local.x * 15.7 + local.y * 11.3 + local.z * 9.1)
+ h += 0.35 * math.cos(local.y * 21.0 + local.x * 8.0)
+ h = max(-0.85, min(0.85, h))
+ local = Vector((
+ local.x * (1.0 + lump * h),
+ local.y * (1.0 + lump * 0.65 * math.sin(local.z * 13.0 + 1.2)),
+ local.z * (1.0 + lump * 0.40 * h),
+ ))
+ v.co = rot @ local + origin
return verts
@@ -200,151 +208,159 @@ def pack_uvs(bm, margin=0.08):
)
-def _stone_size(i, base_xy, base_z, k):
- sx = base_xy[0] + 0.035 * abs(math.sin(i * 2.17 + k))
- sy = base_xy[1] + 0.028 * abs(math.cos(i * 1.73 + k * 0.7))
- sz = base_z + 0.038 * abs(math.sin(i * 1.11 + k * 1.3))
- return sx, sy, sz
-
-
def build_campfire_mesh(name, bevel_offset, bevel_segments):
bm = bmesh.new()
- bevel_verts = []
- ash_faces = set()
- wood_faces = set()
try:
- for i in range(N_RING):
- ang = 2.0 * math.pi * i / N_RING
- sx, sy, sz = _stone_size(i, (0.175, 0.138), 0.070, 0.2)
- r = RING_R + 0.028 * math.sin(i * 1.9)
- loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0)
- tilt = (
- math.radians(18.0 * math.sin(i * 0.7)),
- math.radians(14.0 * math.cos(i * 1.3)),
- ang + math.radians(22.0 * math.sin(i * 1.1)),
- )
- bevel_verts.extend(
- add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.24)
+ # Two-course running-bond ring — same masonry as stone-well, not a
+ # circle of cubes or icos. Bevel stone edges only; logs go on after.
+ stone_verts = []
+ stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88
+ actual_h = STONE_H * 0.90
+ for row in range(N_ROWS):
+ z = actual_h / 2.0 + row * STONE_H
+ rot_off = (row % 2) * (math.pi / N_AROUND)
+ for i in range(N_AROUND):
+ ang = 2.0 * math.pi * i / N_AROUND + rot_off
+ loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z)
+ stone_verts.extend(
+ add_box(
+ bm,
+ loc,
+ (STONE_D, stone_w, actual_h),
+ STONE_IDX,
+ euler=(0.0, 0.0, ang),
+ )
+ )
+
+ for i in range(N_FLOOR):
+ ang = 2.0 * math.pi * i / N_FLOOR + 0.22
+ r = 0.11 + 0.02 * (i % 2)
+ sz = 0.032 + 0.008 * (i % 3)
+ loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50)
+ stone_verts.extend(
+ add_box(
+ bm,
+ loc,
+ (0.078, 0.058, sz),
+ STONE_IDX,
+ euler=(0.0, 0.0, ang),
+ )
)
- for i in range(N_INNER):
- ang = 2.0 * math.pi * i / N_INNER + 0.31
- sx, sy, sz = _stone_size(i, (0.100, 0.082), 0.048, 1.4)
- r = INNER_R + 0.018 * math.cos(i * 1.4)
- loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0)
- tilt = (
- math.radians(12.0 * math.cos(i * 0.9)),
- math.radians(10.0 * math.sin(i * 1.5)),
- ang + 0.4,
- )
- bevel_verts.extend(
- add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.20)
+ if bevel_offset > 0.0:
+ edges = list({e for v in stone_verts for e in v.link_edges})
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
)
- before = set(bm.faces)
- add_cone(
+ add_rock(
bm,
- (0.0, 0.0, ASH_H / 2.0),
- ASH_R,
- 0.055,
- ASH_H,
- 16,
+ (0.0, 0.0, 0.014),
+ (0.10, 0.09, 0.024),
ASH_IDX,
+ lump=0.14,
)
- ash_faces.update(set(bm.faces) - before)
+ for i in range(N_ASH):
+ ang = 2.0 * math.pi * i / N_ASH + 0.18
+ r = 0.022 + 0.040 * ((i % 4) / 3.0)
+ sz = 0.016 + 0.010 * abs(math.sin(i * 1.7))
+ loc = (r * math.cos(ang), r * math.sin(ang), sz * 0.50)
+ add_rock(
+ bm,
+ loc,
+ (0.032 + 0.006 * (i % 3), 0.026, sz),
+ ASH_IDX,
+ euler=(0.0, 0.0, ang),
+ lump=0.16,
+ )
for i in range(N_LOGS):
yaw = i * (math.pi / 2.0) + math.radians(18.0)
loc = (
- 0.045 * math.cos(yaw + math.pi / 4.0),
- 0.045 * math.sin(yaw + math.pi / 4.0),
- LOG_R + 0.020 + 0.012 * (i % 2),
+ 0.04 * math.cos(yaw + math.pi / 2.0),
+ 0.04 * math.sin(yaw + math.pi / 2.0),
+ LOG_R + 0.018 + 0.012 * i,
)
- before = set(bm.faces)
- verts = add_cylinder(
+ add_cone(
bm,
loc,
- LOG_R * (1.0 - 0.08 * (i % 2)),
+ LOG_R * (1.0 - 0.04 * i),
+ LOG_R * 0.88,
LOG_LEN,
12,
WOOD_IDX,
euler=(math.pi / 2.0, 0.0, yaw),
)
- bevel_verts.extend(verts)
- wood_faces.update(set(bm.faces) - before)
+ # Teepee aimed at a shared apex so the sticks read as one fire, not
+ # four independent posts. Cone local +Z tracks base -> apex.
+ z_apex = WALL_H + 0.24
+ z_base = 0.040
+ r_base = R_INNER - 0.05
for i in range(N_STICKS):
- yaw = i * (2.0 * math.pi / N_STICKS) + 0.55
- pitch = math.radians(32.0 + 6.0 * math.sin(i * 1.7))
- loc = (
- 0.07 * math.cos(yaw),
- 0.07 * math.sin(yaw),
- 0.055 + STICK_R,
- )
- before = set(bm.faces)
- verts = add_cylinder(
+ yaw = i * (2.0 * math.pi / N_STICKS) + math.pi / 2.0
+ base = Vector((
+ r_base * math.cos(yaw),
+ r_base * math.sin(yaw),
+ z_base,
+ ))
+ apex = Vector((0.0, 0.0, z_apex))
+ delta = apex - base
+ mid = (base + apex) * 0.5
+ rot = delta.to_track_quat("Z", "Y").to_euler()
+ add_cone(
bm,
- loc,
+ (mid.x, mid.y, mid.z),
STICK_R,
- STICK_LEN,
- 8,
+ STICK_R * 0.88,
+ delta.length,
+ 12,
WOOD_IDX,
- euler=(math.pi / 2.0 - pitch, 0.0, yaw),
+ euler=(rot.x, rot.y, rot.z),
)
- bevel_verts.extend(verts)
- wood_faces.update(set(bm.faces) - before)
for i in range(N_COALS):
- ang = 2.0 * math.pi * i / N_COALS + 0.2
- r = 0.07 + 0.025 * (i % 2)
- sz = 0.028 + 0.010 * abs(math.sin(i * 2.2))
- loc = (r * math.cos(ang), r * math.sin(ang), ASH_H * 0.55 + sz / 2.0)
- before = set(bm.faces)
- verts = add_box(
+ ang = 2.0 * math.pi * i / N_COALS + 0.11
+ r = 0.030 + 0.022 * (i % 3)
+ sz = 0.014 + 0.008 * abs(math.sin(i * 2.2))
+ loc = (r * math.cos(ang), r * math.sin(ang), 0.022 + sz / 2.0)
+ add_rock(
bm,
loc,
- (0.040, 0.028, sz),
+ (0.026, 0.020, sz),
WOOD_IDX,
euler=(0.0, 0.0, ang),
- )
- bevel_verts.extend(verts)
- wood_faces.update(set(bm.faces) - before)
-
- if bevel_offset > 0.0:
- edges = list({e for v in bevel_verts for e in v.link_edges})
- bmesh.ops.bevel(
- bm,
- geom=edges,
- offset=bevel_offset,
- segments=bevel_segments,
- profile=0.5,
- affect="EDGES",
- clamp_overlap=True,
+ lump=0.18,
)
- zmin = min(v.co.z for v in bm.verts)
- if zmin != 0.0:
- for v in bm.verts:
- v.co.z -= zmin
+ for v in bm.verts:
+ if v.co.z < 0.0:
+ v.co.z = 0.0
pack_uvs(bm)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
for face in bm.faces:
- face.smooth = True
+ face.smooth = face.material_index == WOOD_IDX
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(35.0):
- edge.smooth = False
- for f in ash_faces:
- if f.is_valid:
- f.material_index = ASH_IDX
- for f in wood_faces:
- if f.is_valid:
- f.material_index = WOOD_IDX
+ mats = {f.material_index for f in edge.link_faces}
+ if WOOD_IDX in mats and STONE_IDX not in mats and ASH_IDX not in mats:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2:
+ if edge.calc_face_angle() > math.radians(38.0):
+ edge.smooth = False
+ else:
+ edge.smooth = False
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
me.update()
+ for poly in me.polygons:
+ poly.use_smooth = poly.material_index == WOOD_IDX
finally:
bm.free()
obj = bpy.data.objects.new(name, me)
@@ -501,11 +517,11 @@ def export_unity(path, objects):
def check(skip_decimate):
bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_campfire_mesh("CampfireLow", bevel_offset=0.006, bevel_segments=2)
- high = build_campfire_mesh("CampfireHigh", bevel_offset=0.006, bevel_segments=4)
- stone = principled("CampfireStone", (0.46, 0.40, 0.34, 1.0), 0.0, 0.72)
- wood = principled("CampfireWood", (0.14, 0.075, 0.040, 1.0), 0.0, 0.78)
- ash = principled("CampfireAsh", (0.28, 0.26, 0.24, 1.0), 0.0, 0.90)
+ low = build_campfire_mesh("CampfireLow", bevel_offset=0.010, bevel_segments=2)
+ high = build_campfire_mesh("CampfireHigh", bevel_offset=0.010, bevel_segments=4)
+ stone = principled("CampfireStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84)
+ wood = principled("CampfireWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50)
+ ash = principled("CampfireAsh", (0.12, 0.11, 0.10, 1.0), 0.0, 0.94)
assign_slots(low, stone, wood, ash)
assign_slots(high, stone, wood, ash)
@@ -658,8 +674,8 @@ def render_still(low, stone, tex, path, engine):
ob.hide_render = True
ob.hide_viewport = True
- low.rotation_euler.z = math.radians(-36.0)
- low.rotation_euler.x = math.radians(4.0)
+ low.rotation_euler.z = math.radians(-28.0)
+ low.rotation_euler.x = math.radians(0.0)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -705,10 +721,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 = (0.96, -1.33, 0.69)
+ cam.location = (0.99, -1.42, 0.93)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 0.10)
+ aim.location = (0.0, 0.0, 0.16)
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO")
con.target = aim
diff --git a/showcase/campfire/preview.webp b/showcase/campfire/preview.webp
index 22249ea..90e4201 100644
Binary files a/showcase/campfire/preview.webp and b/showcase/campfire/preview.webp differ
diff --git a/showcase/gallery.json b/showcase/gallery.json
index 9a1b572..98fdc4a 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -45,7 +45,7 @@
"name": "campfire",
"dir": "showcase/campfire",
"teaches": "A procedural campfire through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
- "witnessesFix": "Recomputed: 7528 tris, three materials with 116 wood and 18 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.964×0.950×0.252 m, LOD ratios in band, convex collider 105 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "witnessesFix": "Recomputed: 3616 tris, three materials with 100 wood and 48 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.789×0.789×0.410 m, LOD ratios in band, convex collider 254 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
"hero": "docs/gallery/assets/campfire-hero.webp",
"preview": "showcase/campfire/preview.webp",
"tags": [
@@ -57,7 +57,7 @@
"name": "market-stall",
"dir": "showcase/market-stall",
"teaches": "A procedural timber market stall through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
- "witnessesFix": "Recomputed: 2244 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.360×0.964×1.743 m, LOD ratios in band, convex collider 28 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "witnessesFix": "Recomputed: 4404 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.392×0.966×1.743 m, LOD ratios in band, convex collider 46 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
"hero": "docs/gallery/assets/market-stall-hero.webp",
"preview": "showcase/market-stall/preview.webp",
"tags": [
diff --git a/showcase/market-stall/README.md b/showcase/market-stall/README.md
index 823383c..7288aec 100644
--- a/showcase/market-stall/README.md
+++ b/showcase/market-stall/README.md
@@ -1,9 +1,10 @@
# Market stall
-A showcase piece, not an example. Procedural timber stall (posts, slanted
-side plates, counter with apron and shelf, backboard, striped awning and
-valance) 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 timber stall (posts with feet,
+slanted rafters, slatted counter and shelf, back-wall planks, eave fascia,
+striped awning and valance) then the shipped pipeline: unique-cell UVs,
+Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF
+export.
It asserts **budget conformance** of the generated result. It does not
witness an API contract. "It rendered without error" is not a check.
@@ -21,14 +22,14 @@ materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
-| Base triangles | 2140–2350 | 2244 / 2244 / 2244 |
+| Base triangles | 4280–4550 | 4404 / 4404 / 4404 |
| 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.2193 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 |
| Materials | exactly 3 distinct, ≥16 faces per stripe slot | 3 slots, 48 / 48 stripe faces |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
-| Outer AABB | (1.360, 0.964, 1.743) m ± 0.01 | (1.3600, 0.9639, 1.7430), zmin 0 |
-| Collider tris | ≤ 80 | 28 |
-| Export | written, size > 0 | 163992 / 163992 / 163968 bytes |
+| Outer AABB | (1.392, 0.966, 1.743) m ± 0.01 | (1.3915, 0.9659, 1.7430), zmin 0 |
+| Collider tris | ≤ 80 | 46 |
+| Export | written, size > 0 | 316748 / 316748 / 316724 bytes |
DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across
series — the gate is a ratio band, not an exact count. This mesh happened
diff --git a/showcase/market-stall/market_stall.py b/showcase/market-stall/market_stall.py
index 27cc074..01ed396 100644
--- a/showcase/market-stall/market_stall.py
+++ b/showcase/market-stall/market_stall.py
@@ -50,10 +50,10 @@
BRACE = 0.036
BBOX_TOL = 0.01
# Fitted to the generated AABB after locking geometry. Recomputed from bound_box.
-OUTER_SIZE = (1.360, 0.964, 1.743)
+OUTER_SIZE = (1.392, 0.966, 1.743)
-BASE_TRIS_MIN = 2140
-BASE_TRIS_MAX = 2350
+BASE_TRIS_MIN = 4280
+BASE_TRIS_MAX = 4550
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -112,6 +112,27 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
return verts
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True,
+ cap_tris=False,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = geo["verts"]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
def pack_uvs(bm, margin=0.08):
uv = bm.loops.layers.uv.new("UVMap")
faces = list(bm.faces)
@@ -172,6 +193,9 @@ def build_stall_mesh(name, bevel_offset, bevel_segments):
)
for x, y, z, h in posts:
wood_verts.extend(add_box(bm, (x, y, z), (POST, POST, h), WOOD_IDX))
+ wood_verts.extend(
+ add_box(bm, (x, y, 0.018), (POST * 1.45, POST * 1.45, 0.036), WOOD_IDX)
+ )
# Front and back header beams (along X).
wood_verts.extend(
@@ -204,6 +228,18 @@ def build_stall_mesh(name, bevel_offset, bevel_segments):
euler=(pitch, 0.0, 0.0),
)
)
+ # Inner rafters under the awning — the empty underside was a slab of air.
+ for i in range(3):
+ rx = -hx + POST * 2.0 + (i + 1) * (WIDTH - 4.0 * POST) / 4.0
+ wood_verts.extend(
+ add_box(
+ bm,
+ (rx, 0.0, (FRONT_H + BACK_H) / 2.0 - POST * 0.42),
+ (POST * 0.42, slant_len * 0.96, POST * 0.42),
+ WOOD_IDX,
+ euler=(pitch, 0.0, 0.0),
+ )
+ )
# Mid rail on the back wall and two diagonal braces at the sides.
wood_verts.extend(
@@ -231,33 +267,44 @@ def build_stall_mesh(name, bevel_offset, bevel_segments):
)
)
- # Counter top, apron, and four legs.
+ # Slatted counter — crate-lid language, not one fat slab.
cy = -hy + COUNTER_D / 2.0 + 0.04
- wood_verts.extend(
- add_box(
- bm,
- (0.0, cy, COUNTER_Z),
- (WIDTH - 0.04, COUNTER_D, COUNTER_T),
- WOOD_IDX,
+ n_slats = 6
+ slat_gap = 0.010
+ slat_d = (COUNTER_D - 0.02 - (n_slats - 1) * slat_gap) / n_slats
+ y0 = cy - COUNTER_D / 2.0 + 0.01 + slat_d / 2.0
+ for i in range(n_slats):
+ wood_verts.extend(
+ add_box(
+ bm,
+ (0.0, y0 + i * (slat_d + slat_gap), COUNTER_Z),
+ (WIDTH - 0.06, slat_d, COUNTER_T * 0.72),
+ WOOD_IDX,
+ )
)
- )
wood_verts.extend(
add_box(
bm,
- (0.0, cy - COUNTER_D / 2.0 + COUNTER_T / 2.0, COUNTER_Z - 0.09),
- (WIDTH - 0.08, COUNTER_T * 0.7, 0.16),
+ (0.0, cy - COUNTER_D / 2.0 + 0.018, COUNTER_Z - 0.09),
+ (WIDTH - 0.08, 0.036, 0.16),
WOOD_IDX,
)
)
under_shelf_z = 0.38
- wood_verts.extend(
- add_box(
- bm,
- (0.0, cy, under_shelf_z),
- (WIDTH - 0.16, COUNTER_D * 0.78, 0.028),
- WOOD_IDX,
+ n_shelf = 4
+ shelf_d = COUNTER_D * 0.78
+ shelf_gap = 0.010
+ shelf_slat = (shelf_d - (n_shelf - 1) * shelf_gap) / n_shelf
+ sy0 = cy - shelf_d / 2.0 + shelf_slat / 2.0
+ for i in range(n_shelf):
+ wood_verts.extend(
+ add_box(
+ bm,
+ (0.0, sy0 + i * (shelf_slat + shelf_gap), under_shelf_z),
+ (WIDTH - 0.16, shelf_slat, 0.024),
+ WOOD_IDX,
+ )
)
- )
for lx in (-hx + 0.16, hx - 0.16):
for ly in (cy - COUNTER_D * 0.32, cy + COUNTER_D * 0.32):
wood_verts.extend(
@@ -269,12 +316,24 @@ def build_stall_mesh(name, bevel_offset, bevel_segments):
)
)
- # Backboard behind the counter.
+ # Back wall: horizontal planks with gaps, not a felt slab.
+ for i in range(5):
+ z = 0.36 + i * 0.155
+ wood_verts.extend(
+ add_box(
+ bm,
+ (0.0, hy - inset - 0.014, z),
+ (WIDTH - 2.35 * POST, 0.028, 0.122),
+ WOOD_IDX,
+ )
+ )
+
+ # Front eave fascia under the valance.
wood_verts.extend(
add_box(
bm,
- (0.0, hy - inset - 0.012, 0.95),
- (WIDTH - 2.2 * POST, 0.022, 0.55),
+ (0.0, -hy - 0.008, FRONT_H - 0.028),
+ (WIDTH - POST * 0.4, 0.032, 0.048),
WOOD_IDX,
)
)
@@ -663,8 +722,8 @@ def render_still(low, wood, tex, path, engine):
ob.hide_render = True
ob.hide_viewport = True
- low.rotation_euler.z = math.radians(-26.0)
- low.rotation_euler.x = math.radians(3.0)
+ low.rotation_euler.z = math.radians(-18.0)
+ low.rotation_euler.x = math.radians(2.0)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -710,10 +769,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 = (3.51, -5.00, 2.42)
+ cam.location = (3.11, -5.10, 1.60)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 0.88)
+ aim.location = (0.0, -0.10, 0.92)
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO")
con.target = aim
diff --git a/showcase/market-stall/preview.webp b/showcase/market-stall/preview.webp
index 3ede55d..c507a27 100644
Binary files a/showcase/market-stall/preview.webp and b/showcase/market-stall/preview.webp differ