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: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall Stone well: a round brick well with a shingled pyramid roof on posts, rope and bucket, on a dark studio floor Wooden barrel: a staved barrel with iron hoops on a dark studio floor, warm wedge on the back wall -Campfire: a stone ring, crossed logs, and ash mound on a dark studio floor, warm wedge on the back wall -Market stall: a timber frame with a striped awning and counter on a dark studio floor, warm wedge on the back wall +Campfire: a two-course stone fire ring with a tripod of logs on a dark studio floor, warm wedge on the back wall +Market stall: a timber frame with post feet, slatted counter, rafters, and a striped awning on a dark studio floor, warm wedge on the back wall [`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 @@

campfire

campfire render

Rendered headless by the example itself — click to zoom.

-
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.
blender --background --python showcase/campfire/campfire.py --
-

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 @@

wooden-barrel

campfire

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 @@

campfire

market-stall

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

market-stall render

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 --
-

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