"""Builds the Brandling Safari 3D props (fences, entrance gate, signpost, lamp, fishing dock, research tent, crates, barrels, benches, hay bales) procedurally in Blender and exports them as one assets/models/safari.glb. python3 tools/safari_textures.py # paint assets/models/src/*.png first blender -b -P tools/blender_safari.py # headless build + export blender -b -P tools/blender_safari.py -- --preview /tmp/safari_props.png [--reference /assets/models/scenery.glb] # or, inside a running Blender (e.g. via the Blender MCP): exec(compile(open(p).read(), p, "exec"), {"__name__": "__main__", "__file__": p}) Follows the Vibe-HD-2D model conventions (engine tools/blender_scenery.py, docs/ASSET_GUIDE.md): * one root node per model, named after the model; origin at the ground contact point, +Z up in Blender (+Y up in the glTF), long objects along +X, fronts (sign faces, tent door) facing -Y in Blender (+Z in the glTF) * COLOR_0.rgb = tint / baked ambient occlusion, COLOR_0.a = wind weight * emissive materials (lantern glass) glow; child empties are named sockets * everything is single-sided and CCW seen from outside; canvas that is seen from both sides is emitted twice with opposite winding Only the "Safari_Props" collection is touched. """ import math import os import random import sys import bmesh import bpy import mathutils from mathutils import Euler, Matrix, Vector REPO = os.environ.get("SAFARI_REPO") or os.path.dirname(os.path.dirname(os.path.abspath( __file__ if "__file__" in globals() else "/home/gabe/Documents/safari_zone/Brandling-Safari/tools/x"))) SRC = os.path.join(REPO, "assets", "models", "src") OUT = os.path.join(REPO, "assets", "models") ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] def arg(name, default=None): return ARGS[ARGS.index(name) + 1] if name in ARGS and ARGS.index(name) + 1 < len(ARGS) else default # ----------------------------------------------------------------- builder -- class MeshBuilder: """Accumulates faces with per-corner uv / color / normal; materials by name.""" def __init__(self): self.verts, self.faces, self.uvs, self.cols, self.nrms, self.mats = [], [], [], [], [], [] self.mat_names = [] def face(self, pts, uvs, cols, nrms, mat): if mat not in self.mat_names: self.mat_names.append(mat) base = len(self.verts) self.verts.extend(pts) self.faces.append(list(range(base, base + len(pts)))) self.uvs.extend(uvs) self.cols.extend(cols) self.nrms.extend(nrms) self.mats.append(self.mat_names.index(mat)) def build(self, name): me = bpy.data.meshes.new(name) me.from_pydata([tuple(v) for v in self.verts], [], self.faces) for m in self.mat_names: me.materials.append(MAT[m]) me.polygons.foreach_set("material_index", self.mats) me.polygons.foreach_set("use_smooth", [True] * len(self.faces)) uv = me.uv_layers.new(name="UVMap") col = me.color_attributes.new("Col", "FLOAT_COLOR", "CORNER") for li in range(len(me.loops)): # from_pydata keeps corner order uv.data[li].uv = self.uvs[li] col.data[li].color = self.cols[li] me.color_attributes.active_color = col me.validate() me.normals_split_custom_set([tuple(n) for n in self.nrms]) ob = bpy.data.objects.new(name, me) COLLECTION.objects.link(ob) return ob def material(name, image, alpha=False, emission=0.0, roughness=0.85): """Principled material the glTF exporter understands (texture x vertex color).""" mat = bpy.data.materials.get(name) or bpy.data.materials.new(name) mat.use_nodes = True nt = mat.node_tree nt.nodes.clear() out = nt.nodes.new("ShaderNodeOutputMaterial") bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled") bsdf.inputs["Roughness"].default_value = roughness tex = nt.nodes.new("ShaderNodeTexImage") tex.image = bpy.data.images.load(os.path.join(SRC, image), check_existing=True) vc = nt.nodes.new("ShaderNodeVertexColor") vc.layer_name = "Col" mul = nt.nodes.new("ShaderNodeMix") mul.data_type = "RGBA" mul.blend_type = "MULTIPLY" mul.inputs["Factor"].default_value = 1.0 nt.links.new(tex.outputs["Color"], mul.inputs["A"]) nt.links.new(vc.outputs["Color"], mul.inputs["B"]) nt.links.new(mul.outputs["Result"], bsdf.inputs["Base Color"]) if alpha: cut = nt.nodes.new("ShaderNodeMath") # Round -> exported as alphaMode MASK cut.operation = "ROUND" nt.links.new(tex.outputs["Alpha"], cut.inputs[0]) nt.links.new(cut.outputs[0], bsdf.inputs["Alpha"]) mat.surface_render_method = "DITHERED" if emission > 0.0: # exported as emissiveTexture + KHR_materials_emissive_strength nt.links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"]) bsdf.inputs["Emission Strength"].default_value = emission mat.use_backface_culling = not alpha # exported as glTF doubleSided nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) return mat # Texture tile size in metres (u, v) per material: grain/fibres run along v. TEXEL = { "wood": (0.55, 1.1), "planks": (0.9, 0.9), "staves": (0.45, 0.9), "painted_wood": (0.9, 0.9), "log_bark": (0.8, 1.6), "end_grain": (1.0, 1.0), "canvas": (3.0, 3.0), "canvas_green": (2.0, 2.0), "canvas_stripes": (3.0, 3.0), "rope": (0.1, 0.25), "hay": (0.9, 0.9), "iron": (0.4, 0.4), "stone": (1.0, 1.0), "lantern_glass": (1.0, 1.0), } # ------------------------------------------------------------ vertex color -- def smooth01(a, b, x): t = max(0.0, min(1.0, (x - a) / (b - a))) return t * t * (3 - 2 * t) def ao_ground(p, n): """Contact darkening near the ground, darker undersides.""" k = 0.6 + 0.4 * smooth01(-0.05, 0.75, p.z) if n.z < -0.5: k *= 0.72 return (k, k, k) def ao_dock(p, n): """Dock: bright deck, dim underside, damp greenish posts towards the water.""" k = 0.5 + 0.5 * smooth01(-1.3, -0.05, p.z) if n.z > 0.5 and p.z > -0.03: k = 1.0 if n.z < -0.5: k *= 0.7 wet = 1.0 - smooth01(-1.1, -0.6, p.z) return (k * (1 - 0.25 * wet), k * (1 - 0.1 * wet), k * (1 - 0.35 * wet)) def ao_inside(p, n): k = 0.42 + 0.22 * smooth01(0.0, 2.2, p.z) return (k, k * 0.98, k * 0.94) def ao_flat(k): return lambda p, n: (k, k, k) AO = [ao_ground] # current model's default # --------------------------------------------------------------------- uvs -- def uv_box(grain=2, off=(0.0, 0.0)): """Box projection in the part's local frame; the grain axis maps to v.""" def f(face, tu, tv): n = face.normal ax = max(range(3), key=lambda i: abs(n[i])) others = [i for i in range(3) if i != ax] if grain in others: va = grain ua = others[0] if others[1] == grain else others[1] else: ua, va = others return [(l.vert.co[ua] / tu + off[0], l.vert.co[va] / tv + off[1]) for l in face.loops] return f def uv_cyl(radius, off=(0.0, 0.0)): """Cylindrical mapping around local Z with a whole number of tiles around.""" def f(face, tu, tv): rep = max(1, round(math.tau * radius / tu)) us = [(math.atan2(l.vert.co.y, l.vert.co.x) / math.tau) % 1.0 * rep for l in face.loops] if max(us) - min(us) > rep / 2: # face straddles the seam us = [u + rep if u < rep / 2 else u for u in us] return [(u + off[0], l.vert.co.z / tv + off[1]) for u, l in zip(us, face.loops)] return f def uv_cap(radius, off=(0.0, 0.0)): """Planar end-grain mapping: the texture's circle fits the cap radius.""" def f(face, tu, tv): return [(l.vert.co.x / (2 * radius) + 0.5 + off[0], l.vert.co.y / (2 * radius) + 0.5 + off[1]) for l in face.loops] return f def uv_torus(R, r): def f(face, tu, tv): out = [] for l in face.loops: p = l.vert.co phi = math.atan2(p.y, p.x) % math.tau th = math.atan2(p.z, math.hypot(p.x, p.y) - R) % math.tau out.append((th / math.tau * max(1, round(math.tau * r / tu)), phi * R / tv)) for k in (0, 1): # unwrap seams vals = [uv[k] for uv in out] span = max(1, round(math.tau * r / tu)) if k == 0 else math.tau * R / tv if max(vals) - min(vals) > span / 2: out = [tuple(c + span if (i == k and c < span / 2) else c for i, c in enumerate(uv)) for uv in out] return out return f def uv_by_mat(table): return lambda face, tu, tv: table[face.material_index](face, tu, tv) # ------------------------------------------------------------------- emit -- def emit(mb, bm, mats, M, uvf, ao=None, tint=(1.0, 1.0, 1.0), wind=0.0, flip=False): """Appends a local-space bmesh, transformed by M (rotation + translation), to the builder. Normals are area-weighted vertex normals, so bevelled chamfers shade softly while big faces stay flat (a 'harden normals' look).""" ao = ao or AO[0] if isinstance(tint, (int, float)): tint = (tint, tint, tint) bm.normal_update() bm.verts.index_update() vn = [] for v in bm.verts: n = Vector() for f in v.link_faces: n += f.normal * f.calc_area() vn.append(n.normalized() if n.length > 1e-12 else v.normal.copy()) R = M.to_3x3() s = -1.0 if flip else 1.0 for f in bm.faces: mat = mats[f.material_index] uvs = uvf(f, *TEXEL[mat]) pts = [M @ l.vert.co for l in f.loops] nrm = [(R @ (vn[l.vert.index] * s)).normalized() for l in f.loops] cols = [] for p, n in zip(pts, nrm): k = ao(p, n) cols.append((k[0] * tint[0], k[1] * tint[1], k[2] * tint[2], wind)) if flip: pts, uvs, cols, nrm = pts[::-1], uvs[::-1], cols[::-1], nrm[::-1] mb.face(pts, uvs, cols, nrm, mat) bm.free() # -------------------------------------------------------------- primitives -- def bevel_sharp(bm, width, segments=1, angle=0.6): if width <= 0: return bm.normal_update() edges = [e for e in bm.edges if e.is_manifold and e.calc_face_angle(0.0) > angle] verts = list({v for e in edges for v in e.verts}) bmesh.ops.bevel(bm, geom=edges + verts, offset=width, segments=segments, profile=0.5, affect="EDGES", clamp_overlap=True, material=-1) def bm_box(sx, sy, sz, bevel=0.02, segments=1, base=False, subdiv=0): """Box centred on the origin (base=True: spans z 0..sz).""" bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) if subdiv: bmesh.ops.subdivide_edges(bm, edges=bm.edges[:], cuts=subdiv, use_grid_fill=True) for v in bm.verts: v.co = Vector((v.co.x * sx, v.co.y * sy, v.co.z * sz + (sz / 2 if base else 0.0))) bevel_sharp(bm, min(bevel, sx * 0.45, sy * 0.45, sz * 0.45), segments) return bm def bm_lathe(profile, sides=12, caps=(True, True), phase=0.0, bevel=0.0, noise=0.0, seed=0, noise_scale=1.4): """Surface of revolution around local +Z from (radius, z) pairs (bottom to top); cap faces get material index 1. noise > 0 makes it a lumpy log.""" bm = bmesh.new() off = Vector((seed * 7.31, seed * 3.17, seed * 1.93)) rings = [] for r, z in profile: ring = [] for j in range(sides): a = phase + j / sides * math.tau k = 1.0 if noise: q = Vector((math.cos(a) * noise_scale, math.sin(a) * noise_scale, z * noise_scale * 1.2)) + off k += noise * mathutils.noise.noise(q) ring.append(bm.verts.new((math.cos(a) * r * k, math.sin(a) * r * k, z))) rings.append(ring) for i in range(len(rings) - 1): for j in range(sides): bm.faces.new((rings[i][j], rings[i][(j + 1) % sides], rings[i + 1][(j + 1) % sides], rings[i + 1][j])) if caps[0]: bm.faces.new(rings[0][::-1]).material_index = 1 if caps[1]: bm.faces.new(rings[-1]).material_index = 1 bevel_sharp(bm, bevel) return bm def bm_prism(outline, depth, bevel=0.0, segments=1): """CCW outline in local XY extruded along Z (-depth/2..depth/2); the two outline faces get material index 1.""" bm = bmesh.new() bot = [bm.verts.new((x, y, -depth / 2)) for x, y in outline] top = [bm.verts.new((x, y, depth / 2)) for x, y in outline] n = len(outline) for i in range(n): bm.faces.new((bot[i], bot[(i + 1) % n], top[(i + 1) % n], top[i])) bm.faces.new(bot[::-1]).material_index = 1 bm.faces.new(top).material_index = 1 bevel_sharp(bm, bevel, segments) return bm def bm_torus(R, r, n_major=16, n_minor=6): bm = bmesh.new() vs = [[bm.verts.new(((R + r * math.cos(t)) * math.cos(p), (R + r * math.cos(t)) * math.sin(p), r * math.sin(t))) for t in (j / n_minor * math.tau for j in range(n_minor))] for p in (i / n_major * math.tau for i in range(n_major))] for i in range(n_major): for j in range(n_minor): i1, j1 = (i + 1) % n_major, (j + 1) % n_minor bm.faces.new((vs[i][j], vs[i1][j], vs[i1][j1], vs[i][j1])) return bm def bm_grid(fn, nu, nv): """Open surface from fn(u, v) -> Vector over [0,1]^2 (normal = du x dv).""" bm = bmesh.new() vs = [[bm.verts.new(fn(i / nu, j / nv)) for j in range(nv + 1)] for i in range(nu + 1)] for i in range(nu): for j in range(nv): bm.faces.new((vs[i][j], vs[i + 1][j], vs[i + 1][j + 1], vs[i][j + 1])) return bm def ico(subdiv): """Unit icosphere vertices + triangles (as in the engine's blender_scenery.py).""" t = (1 + 5 ** 0.5) / 2 v = [Vector(x).normalized() for x in [(-1, t, 0), (1, t, 0), (-1, -t, 0), (1, -t, 0), (0, -1, t), (0, 1, t), (0, -1, -t), (0, 1, -t), (t, 0, -1), (t, 0, 1), (-t, 0, -1), (-t, 0, 1)]] f = [(0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11), (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6), (7, 1, 8), (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9), (4, 9, 5), (2, 4, 11), (6, 2, 10), (8, 6, 7), (9, 8, 1)] for _ in range(subdiv): cache, nf = {}, [] def mid(a, b): key = (min(a, b), max(a, b)) if key not in cache: cache[key] = len(v) v.append(((v[a] + v[b]) / 2).normalized()) return cache[key] for a, b, c in f: ab, bc, ca = mid(a, b), mid(b, c), mid(c, a) nf += [(a, ab, ca), (b, bc, ab), (c, ca, bc), (ab, bc, ca)] f = nf return v, f # ---------------------------------------------------------------- frames -- def T(x=0.0, y=0.0, z=0.0, rx=0.0, ry=0.0, rz=0.0): return Matrix.Translation((x, y, z)) @ Euler((rx, ry, rz), "XYZ").to_matrix().to_4x4() def along(a, b, roll=0.0): """Frame at a whose local +Z points to b, local X kept horizontal.""" a, b = Vector(a), Vector(b) d = (b - a).normalized() x = d.cross(Vector((0, 0, 1))) x = Vector((1, 0, 0)) if x.length < 1e-4 else x.normalized() y = d.cross(x) m = Matrix((x, y, d)).transposed().to_4x4() m.translation = a return m @ Matrix.Rotation(roll, 4, "Z") def rnd_off(rng): return (rng.random(), rng.random()) # ------------------------------------------------------------ part helpers -- def box(mb, M, size, mat, rng, bevel=0.02, grain=2, base=False, segments=1, **kw): emit(mb, bm_box(*size, bevel=bevel, base=base, segments=segments), [mat], M, uv_box(grain, rnd_off(rng)), **kw) def beam(mb, a, b, w, h, mat, rng, bevel=0.02, roll=0.0, **kw): """Squared timber from a to b (w across, h the other way), grain along it.""" L = (Vector(b) - Vector(a)).length box(mb, along(a, b, roll), (w, h, L), mat, rng, bevel=bevel, grain=2, base=True, **kw) def log(mb, a, b, r0, r1, rng, mat="log_bark", cap="end_grain", sides=12, bevel=0.025, noise=0.05, caps=(True, True), **kw): """Round log from a to b, bark on the sides and end grain on the caps.""" L = (Vector(b) - Vector(a)).length bm = bm_lathe([(r0, 0.0), ((r0 + r1) / 2, L / 2), (r1, L)], sides, caps=caps, bevel=bevel, noise=noise, seed=rng.randint(0, 999)) uvf = uv_by_mat({0: uv_cyl((r0 + r1) / 2, rnd_off(rng)), 1: uv_cap(max(r0, r1) * (1 + noise), rnd_off(rng))}) emit(mb, bm, [mat, cap], along(a, b), uvf, **kw) def rope(mb, a, b, r=0.016, rng=None, **kw): L = (Vector(b) - Vector(a)).length bm = bm_lathe([(r, 0.0), (r, L)], 6, caps=(False, False)) emit(mb, bm, ["rope", "rope"], along(a, b), uv_cyl(r), **kw) def torus(mb, M, R, r, mat="rope", n_major=16, n_minor=6, **kw): emit(mb, bm_torus(R, r, n_major, n_minor), [mat], M, uv_torus(R, r), **kw) def lashing(mb, centre, radius, turns=3, r=0.022, pitch=0.045, axis=None, **kw): """A few rope wraps around a log (rings stacked along the log axis).""" for i in range(turns): dz = (i - (turns - 1) / 2) * pitch M = (axis or Matrix.Identity(4)) @ T(0, 0, dz) M.translation = Vector(centre) + (axis.to_3x3() @ Vector((0, 0, dz)) if axis else Vector((0, 0, dz))) torus(mb, M, radius + r * 0.6, r, n_major=18, **kw) def pyramid_cap(mb, x, y, z, half, rng, mat="wood", h=None): """Four-sided pointed cap for square posts.""" r = half * 1.12 / math.cos(math.pi / 4) bm = bm_lathe([(r, 0.0), (r, half * 0.25), (r * 0.12, half * 0.25 + (h or half * 0.85))], 4, caps=(True, True), phase=math.pi / 4, bevel=0.008) emit(mb, bm, [mat, mat], T(x, y, z), uv_box(2, rnd_off(rng))) def square_post(mb, x, y, h, w, rng, mat="wood", cap=True, tint=1.0, tilt=(0.0, 0.0)): M = T(x, y, -0.06, tilt[0], tilt[1], 0.0) box(mb, M, (w, w, h + 0.06), mat, rng, bevel=0.022, base=True, tint=tint) if cap: top = M @ Vector((0, 0, h + 0.06)) pyramid_cap(mb, top.x, top.y, top.z, w / 2, rng, mat) def socket(ob, name, p): s = bpy.data.objects.new(name, None) s.empty_display_size = 0.15 s.location = p COLLECTION.objects.link(s) s.parent = ob return s # --------------------------------------------------------------- lantern -- def lantern(mb, top, rng, s=1.0): """Hanging iron lantern with glowing glass; returns the flame position.""" x, y, z = top ring_r = 0.03 * s torus(mb, T(x, y, z - ring_r, rx=math.pi / 2), ring_r, 0.008 * s, mat="iron", n_major=10, n_minor=5) z -= 2 * ring_r hcap = 0.08 * s bm = bm_lathe([(0.12 * s, 0.0), (0.035 * s, hcap), (0.02 * s, hcap + 0.015 * s)], 4, phase=math.pi / 4, bevel=0.005 * s) emit(mb, bm, ["iron", "iron"], T(x, y, z - hcap - 0.015 * s), uv_box(2)) z -= hcap + 0.015 * s box(mb, T(x, y, z - 0.015 * s), (0.2 * s, 0.2 * s, 0.03 * s), "iron", rng, bevel=0.006 * s, ao=ao_flat(0.9)) z -= 0.03 * s gh, g = 0.19 * s, 0.075 * s for cx, cy in ((-1, -1), (1, -1), (1, 1), (-1, 1)): box(mb, T(x + cx * 0.08 * s, y + cy * 0.08 * s, z - gh / 2), (0.024 * s, 0.024 * s, gh), "iron", rng, bevel=0.004 * s, ao=ao_flat(0.85)) z0, z1 = z - gh, z white = (1.0, 1.0, 1.0, 0.0) for n in (Vector((1, 0, 0)), Vector((0, 1, 0)), Vector((-1, 0, 0)), Vector((0, -1, 0))): t = Vector((-n.y, n.x, 0)) # CCW seen from outside c = Vector((x, y, 0)) + n * g pts = [c - t * g + Vector((0, 0, z0)), c + t * g + Vector((0, 0, z0)), c + t * g + Vector((0, 0, z1)), c - t * g + Vector((0, 0, z1))] mb.face(pts, [(0, 0), (1, 0), (1, 1), (0, 1)], [white] * 4, [n] * 4, "lantern_glass") flame = Vector((x, y, (z0 + z1) / 2)) z = z0 box(mb, T(x, y, z - 0.018 * s), (0.2 * s, 0.2 * s, 0.036 * s), "iron", rng, bevel=0.007 * s, ao=ao_flat(0.8)) bm = bm_lathe([(0.05 * s, 0.0), (0.01 * s, -0.035 * s)], 4, phase=math.pi / 4, caps=(True, False)) emit(mb, bm, ["iron", "iron"], T(x, y, z - 0.036 * s), uv_box(2), ao=ao_flat(0.7)) return flame def chain(mb, top, bottom_z, link=0.045): x, y, z = top n = max(1, int(round((z - bottom_z) / link))) for i in range(n): zc = z - (i + 0.5) * (z - bottom_z) / n rz = 0.0 if i % 2 == 0 else math.pi / 2 bm = bm_torus(0.02, 0.006, 8, 4) for v in bm.verts: v.co.y *= 1.6 # oval link, long axis vertical after the rotation below emit(mb, bm, ["iron"], T(x, y, zc, rx=math.pi / 2, rz=rz) @ T(rz=0.0), uv_torus(0.02, 0.006), ao=ao_flat(0.85)) # ------------------------------------------------------------------ models -- FENCE_H, POST_W = 0.92, 0.15 def fence_post_parts(mb, x, rng): # Deterministic (no jitter) so chained fence segments' shared posts coincide exactly. square_post(mb, x, 0.0, FENCE_H - 0.06, POST_W, random.Random(7)) def make_fence(name, seed): rng = random.Random(seed) mb = MeshBuilder() for x in (-1.0, 1.0): fence_post_parts(mb, x, rng) for z in (0.34, 0.68): # rails slightly askew, ends buried in the posts a = Vector((-0.98, rng.uniform(-0.008, 0.008), z + rng.uniform(-0.015, 0.015))) b = Vector((0.98, rng.uniform(-0.008, 0.008), z + rng.uniform(-0.015, 0.015))) beam(mb, a, b, 0.065, 0.13, "wood", rng, bevel=0.02, tint=rng.uniform(0.92, 1.06)) return mb.build(name) def make_fence_post(name, seed): mb = MeshBuilder() fence_post_parts(mb, 0.0, random.Random(seed)) return mb.build(name) def rock(mb, c, scale, rng, sink=0.1): v, f = ico(2) off = Vector((rng.random() * 100, rng.random() * 100, rng.random() * 100)) pos = [] for p in v: k = 1 + 0.3 * mathutils.noise.noise(p * 1.3 + off) + 0.08 * mathutils.noise.noise(p * 3.5 + off) q = Vector((p.x * scale[0], p.y * scale[1], p.z * scale[2])) * k q.z = max(q.z, -sink) pos.append(q) bm = bmesh.new() vs = [bm.verts.new(p) for p in pos] for tri in f: bm.faces.new([vs[i] for i in tri]) def col(p, n): moss = smooth01(0.45, 0.85, n.z) * (0.6 + 0.4 * mathutils.noise.noise(p * 3 + off)) k = 0.55 + 0.45 * smooth01(0.0, scale[2] * 0.9, p.z) t = Vector((1, 1, 1)).lerp(Vector((0.6, 1.0, 0.4)), max(0.0, min(1.0, moss))) return (t.x * k, t.y * k, t.z * k) emit(mb, bm, ["stone"], T(*c, rz=rng.random() * math.tau), uv_box(2, rnd_off(rng)), ao=col) def make_gate(name, seed): """Safari Zone entrance: two log pillars, a log lintel over a squared tie beam, a hanging blank plank sign and two hanging lanterns (light_0/1).""" rng = random.Random(seed) mb = MeshBuilder() PX, TOP = 1.7, 3.12 for s in (-1, 1): x = s * PX bm = bm_lathe([(0.5, -0.06), (0.46, 0.08), (0.41, 0.3), (0.395, 1.2), (0.38, 2.4), (0.37, TOP)], 14, caps=(False, True), bevel=0.03, noise=0.05, seed=rng.randint(0, 99)) emit(mb, bm, ["log_bark", "end_grain"], T(x, 0, 0, rz=rng.random() * math.tau), uv_by_mat({0: uv_cyl(0.4, rnd_off(rng)), 1: uv_cap(0.39)})) lashing(mb, (x, 0, 2.52), 0.405, turns=3) lashing(mb, (x, 0, 0.62), 0.43, turns=2) for k in range(3): # boulders at the foot a = (s * 0.9 + k * 0.7 + rng.uniform(-0.2, 0.2)) * math.pi d = rng.uniform(0.5, 0.62) sc = rng.uniform(0.17, 0.26) rock(mb, (x + math.cos(a) * d, math.sin(a) * d, 0.0), (sc * 1.2, sc, sc * 0.8), rng) # Lintel log resting on the pillars, overhanging both sides. log(mb, (-2.45, 0, TOP + 0.2), (2.45, 0, TOP + 0.2), 0.23, 0.21, rng, sides=14, noise=0.04) # Squared tie beam through the pillars just below it. beam(mb, (-2.32, 0, 2.86), (2.32, 0, 2.86), 0.24, 0.22, "wood", rng, bevel=0.03) # Pegs locking the tie beam outside the pillars. for s in (-1, 1): log(mb, (s * 2.2, 0, 2.7), (s * 2.2, 0, 3.03), 0.035, 0.035, rng, mat="wood", sides=8, bevel=0.008, noise=0.0) beam_bottom = 2.86 - 0.11 # Hanging sign: painted plank board in a dark frame on two ropes. sw, sh, sz = 1.7, 0.52, 2.42 box(mb, T(0, 0, sz), (sw, 0.06, sh), "painted_wood", rng, bevel=0.015, grain=0) for z in (sz - sh / 2 - 0.02, sz + sh / 2 + 0.02): box(mb, T(0, 0, z), (sw + 0.12, 0.1, 0.07), "wood", rng, bevel=0.018, grain=0, tint=0.72) for x in (-sw / 2 - 0.03, sw / 2 + 0.03): box(mb, T(x, 0, sz), (0.07, 0.1, sh), "wood", rng, bevel=0.018, grain=2, tint=0.72) for x in (-0.62, 0.62): top = sz + sh / 2 + 0.055 torus(mb, T(x, 0, top + 0.03, rx=math.pi / 2), 0.03, 0.008, mat="iron", n_major=10, n_minor=5) rope(mb, (x, 0, top + 0.055), (x, 0, beam_bottom - 0.035)) torus(mb, T(x, 0, beam_bottom - 0.025, rx=math.pi / 2), 0.03, 0.008, mat="iron", n_major=10, n_minor=5) # Lanterns hanging from the tie beam, inside the pillars. flames = [] for s in (-1, 1): x = s * 1.08 box(mb, T(x, 0, beam_bottom - 0.01), (0.07, 0.07, 0.04), "iron", rng, bevel=0.006) chain(mb, (x, 0, beam_bottom - 0.03), beam_bottom - 0.2) flames.append(lantern(mb, (x, 0, beam_bottom - 0.2), rng, s=1.05)) ob = mb.build(name) for i, p in enumerate(flames): socket(ob, f"light_{i}", p) return ob def make_signpost(name, seed): rng = random.Random(seed) mb = MeshBuilder() square_post(mb, 0.0, 0.0, 1.32, 0.13, rng, tilt=(0.0, 0.02)) arrow = [(-0.5, -0.13), (0.36, -0.13), (0.52, 0.0), (0.36, 0.13), (-0.5, 0.13)] boards = [(1.08, 0.18, 1, 0.03), (0.78, -0.16, -1, -0.06)] for z, x, d, roll in boards: pts = [(px * d, py) for px, py in arrow] if d < 0: pts = pts[::-1] # keep CCW after mirroring bm = bm_prism(pts, 0.05, bevel=0.012) M = T(x, -0.095, z, rx=math.pi / 2, ry=roll) # local Y -> up, local Z -> -Y (faces the camera) emit(mb, bm, ["wood", "painted_wood"], M, uv_box(0, rnd_off(rng))) for k in (-0.08, 0.08): # nails into the post nail = T(0.0, -0.125, z + k, rx=math.pi / 2) emit(mb, bm_lathe([(0.012, 0.0), (0.012, 0.01)], 6), ["iron", "iron"], nail, uv_box(2)) return mb.build(name) def make_lamp(name, seed): """Wooden lamp post with an iron hook arm and a hanging lantern (socket 'light').""" rng = random.Random(seed) mb = MeshBuilder() box(mb, T(0, 0, -0.04), (0.32, 0.32, 0.2), "wood", rng, bevel=0.04, base=True, tint=0.85) square_post(mb, 0.0, 0.0, 2.08, 0.16, rng) arm_z = 1.98 beam(mb, (-0.06, 0, arm_z), (0.62, 0, arm_z), 0.07, 0.09, "wood", rng, bevel=0.015) beam(mb, (0.06, 0, 1.6), (0.38, 0, arm_z - 0.03), 0.06, 0.06, "wood", rng, bevel=0.012) # knee brace box(mb, T(0.0, 0, arm_z), (0.18, 0.18, 0.05), "iron", rng, bevel=0.008) # iron collar hx = 0.5 torus(mb, T(hx, 0, arm_z - 0.06, rx=math.pi / 2), 0.035, 0.009, mat="iron", n_major=10, n_minor=5) chain(mb, (hx, 0, arm_z - 0.09), arm_z - 0.17) flame = lantern(mb, (hx, 0, arm_z - 0.17), rng, s=1.3) ob = mb.build(name) socket(ob, "light", flame) return ob DOCK_LEN, DOCK_W, DECK_T = 2.0, 1.6, 0.07 def dock_parts(mb, rng, end=False): AO[0] = ao_dock n = 10 pitch = DOCK_LEN / n for i in range(n): x = -DOCK_LEN / 2 + (i + 0.5) * pitch dz = -rng.uniform(0.0, 0.008) box(mb, T(x, rng.uniform(-0.02, 0.02), -DECK_T / 2 + dz, rz=rng.uniform(-0.012, 0.012)), (pitch - 0.016, DOCK_W, DECK_T), "wood", rng, bevel=0.012, grain=1, tint=rng.uniform(0.88, 1.08)) for s in (-1, 1): beam(mb, (-DOCK_LEN / 2, s * 0.55, -0.15), (DOCK_LEN / 2, s * 0.55, -0.15), 0.11, 0.16, "wood", rng, bevel=0.015, tint=0.8) # stringers beam(mb, (-DOCK_LEN / 2, s * 0.82, -0.09), (DOCK_LEN / 2, s * 0.82, -0.09), 0.045, 0.13, "wood", rng, bevel=0.01, tint=0.9) # fascia xs = [-0.5, 0.5] + ([0.94] if end else []) for x in xs: for s in (-1, 1): log(mb, (x, s * 0.88, -1.55), (x, s * 0.88, 0.16), 0.085, 0.08, rng, mat="wood", sides=10, noise=0.03, bevel=0.02, tint=0.95) beam(mb, (x, -0.95, -0.3), (x, 0.95, -0.3), 0.12, 0.12, "wood", rng, bevel=0.015, tint=0.75) # cap beam beam(mb, (x + 0.07, -0.86, -0.36), (x + 0.07, 0.86, -1.25), 0.05, 0.11, "wood", rng, bevel=0.01, tint=0.7) # diagonal brace if end: beam(mb, (DOCK_LEN / 2 + 0.025, -0.84, -0.09), (DOCK_LEN / 2 + 0.025, 0.84, -0.09), 0.045, 0.13, "wood", rng, bevel=0.01, tint=0.9) # end fascia # Mooring bollard with rope wraps and a coil of rope on the deck. bx, by = 0.72, 0.6 log(mb, (bx, by, -1.6), (bx, by, 0.5), 0.13, 0.12, rng, sides=12, noise=0.05, bevel=0.03) AO[0] = ao_ground lashing(mb, (bx, by, 0.28), 0.125, turns=3, r=0.024, pitch=0.05) path = [Vector((bx - 0.1, by - 0.06, 0.24)), Vector((0.58, 0.32, 0.1)), Vector((0.5, 0.05, 0.03)), Vector((0.42, -0.2, 0.025))] # droops from the bollard down onto the deck for a, b in zip(path[:-1], path[1:]): rope(mb, a - (b - a).normalized() * 0.01, b, r=0.022) for i in range(3): torus(mb, T(0.3, -0.35, 0.022 + i * 0.04), 0.17 - i * 0.03, 0.022, n_major=20) AO[0] = ao_ground def make_dock(name, seed): mb = MeshBuilder() dock_parts(mb, random.Random(seed)) return mb.build(name) def make_dock_end(name, seed): mb = MeshBuilder() dock_parts(mb, random.Random(seed), end=True) return mb.build(name) def crate_parts(mb, M, size, rng): h = size / 2 t = size * 0.13 box(mb, M, (size - 0.02, size - 0.02, size - 0.02), "planks", rng, bevel=0.01, grain=2, tint=rng.uniform(0.9, 1.05)) for cx, cy in ((-1, -1), (1, -1), (1, 1), (-1, 1)): # corner posts box(mb, M @ T(cx * (h - t / 2), cy * (h - t / 2), 0), (t, t, size), "wood", rng, bevel=0.012, tint=0.8) for z in (-1, 1): # top and bottom battens between the corner posts for ax in (0, 1): for s in (-1, 1): p = [0.0, 0.0, z * (h - t / 2)] p[1 - ax] = s * (h - t * 0.45) dims = [size - 2 * t, t * 0.9, t] if ax == 0 else [t * 0.9, size - 2 * t, t] box(mb, M @ T(*p), dims, "wood", rng, bevel=0.01, grain=ax, tint=0.85) L = math.hypot(size - 2 * t, size - 2 * t) for s in (-1, 1): # diagonal braces on the front and back box(mb, M @ T(0, s * (h - t * 0.4), 0, ry=math.pi / 4 * s), (t * 0.85, t * 0.7, L - t * 0.4), "wood", rng, bevel=0.01, grain=2, tint=0.88) def make_crate(name, seed): rng = random.Random(seed) mb = MeshBuilder() s = 0.72 crate_parts(mb, T(0, 0, s / 2, rz=0.0), s, rng) return mb.build(name) def make_barrel(name, seed): rng = random.Random(seed) mb = MeshBuilder() H, re, rm = 0.88, 0.25, 0.3 def r_at(z): return re + (rm - re) * math.sin(math.pi * max(0.0, min(1.0, z / H))) prof = [(r_at(z), z) for z in (H * i / 10 for i in range(11))] prof += [(re - 0.005, H + 0.012), (re - 0.03, H + 0.012), (re - 0.03, H - 0.02)] bm = bm_lathe(prof, 18, caps=(True, True), bevel=0.0) off = rnd_off(rng) emit(mb, bm, ["staves", "planks"], T(rz=rng.random() * math.tau), uv_by_mat({0: uv_cyl(0.28, off), 1: uv_box(1, off)})) for z0 in (0.07, 0.25, H - 0.31, H - 0.13): z1 = z0 + 0.06 a, b = r_at(z0) + 0.006, r_at(z1) + 0.006 bm = bm_lathe([(a - 0.006, z0), (a + 0.006, z0 + 0.008), (b + 0.006, z1 - 0.008), (b - 0.006, z1)], 18, caps=(False, False)) emit(mb, bm, ["iron"], T(), uv_cyl(0.3)) return mb.build(name) def make_log_bench(name, seed): rng = random.Random(seed) mb = MeshBuilder() r, L, seat = 0.2, 1.5, 0.45 half = [(r * math.cos(a), r * math.sin(a)) for a in (math.pi + i / 8 * math.pi for i in range(9))] bm = bm_prism(half, L, bevel=0.02) bm.normal_update() for f in bm.faces: # the sawn top face gets the plain-wood material if f.material_index == 0 and f.normal.y > 0.9: f.material_index = 2 # local x -> world Y, local y -> world Z (flat top up), local z -> world X (length) M = Matrix((Vector((0, 1, 0)), Vector((0, 0, 1)), Vector((1, 0, 0)))).transposed().to_4x4() M.translation = Vector((0, 0, seat)) emit(mb, bm, ["log_bark", "end_grain", "wood"], M, uv_by_mat({0: uv_cyl(r, rnd_off(rng)), 1: uv_cap(r), 2: uv_box(2, rnd_off(rng))})) for x in (-0.5, 0.5): log(mb, (x, 0, -0.04), (x, 0, seat - r + 0.03), 0.17, 0.16, rng, sides=12, noise=0.05) return mb.build(name) def make_hay_bale(name, seed): rng = random.Random(seed) mb = MeshBuilder() sx, sy, sz = 1.0, 0.56, 0.48 bm = bm_box(sx, sy, sz, bevel=0.07, segments=3, base=False, subdiv=4) off = Vector((rng.random() * 50, rng.random() * 50, 0)) bm.normal_update() for v in bm.verts: v.co += v.normal * 0.018 * mathutils.noise.noise(v.co * 4.0 + off) emit(mb, bm, ["hay"], T(0, 0, sz / 2 - 0.02), uv_box(1, rnd_off(rng))) for x in (-0.26, 0.26): # twine bands ring = [(-sy / 2 - 0.012, -sz / 2 + 0.05), (sy / 2 + 0.012, -sz / 2 + 0.05), (sy / 2 + 0.012, sz / 2 - 0.05), (sy / 2 - 0.05, sz / 2 + 0.012), (-sy / 2 + 0.05, sz / 2 + 0.012), (-sy / 2 - 0.012, sz / 2 - 0.05)] for i in range(len(ring)): (ya, za), (yb, zb) = ring[i], ring[(i + 1) % len(ring)] rope(mb, (x, ya, za + sz / 2 - 0.02), (x, yb, zb + sz / 2 - 0.02), r=0.012) return mb.build(name) # ------------------------------------------------------------------- tent -- TW, TD, EAVE, RIDGE, OVX, OVY = 1.5, 1.4, 1.15, 2.36, 0.16, 0.12 DOOR_W, DOOR_H = 0.56, 1.72 def roof_z(x): return RIDGE - (RIDGE - EAVE) * abs(x) / TW def both_sides(mb, make_bm, mats, uvf, ao_out, ao_in=ao_inside, M=None, **kw): """Canvas seen from both sides: outer and inner (reversed) copies.""" emit(mb, make_bm(), mats, M or T(), uvf, ao=ao_out, **kw) emit(mb, make_bm(), mats, M or T(), uvf, ao=ao_in, flip=True, **kw) def roof_sag(x, y): """Canvas sags between the ridge and the eaves, most between the rafters.""" u = min(1.0, abs(x) / (TW + OVX) * 1.05) sy = math.sin(math.pi * abs(y) / (TD + OVY)) ** 2 # zero at the gable ends and the middle rafter return 0.06 * math.sin(math.pi * u) * (0.35 + 0.65 * sy) def make_tent(name, seed): """Research tent / ranger station: cream canvas wall tent with a striped roof, open door (flap rolled up) facing -Y, guy ropes, ridge pole, pennant.""" rng = random.Random(seed) mb = MeshBuilder() Dy = TD + OVY for s in (-1, 1): # roof slopes def roof(u, v, s=s): x = s * u * (TW + OVX) y = -Dy + 2 * Dy * v z = roof_z(x) - roof_sag(x, y) return Vector((x, y, z)) mk = (lambda roof=roof, s=s: bm_grid(roof, 6, 10) if s > 0 else _flip_grid(bm_grid(roof, 6, 10))) both_sides(mb, mk, ["canvas_stripes"], lambda f, tu, tv: [(l.vert.co.y / tu, abs(l.vert.co.x) * 1.25 / tv) for l in f.loops], ao_out=lambda p, n: (0.82 + 0.18 * smooth01(1.0, 2.3, p.z),) * 3) def wall_side(s): def fn(u, v): y = -TD + 2 * TD * u z = v * (EAVE - 0.03) bulge = 0.05 * math.sin(math.pi * u) * math.sin(math.pi * v) return Vector((s * (TW - bulge + 0.03 * (1 - v)), y, z)) bm = bm_grid(fn, 8, 3) return bm if s > 0 else _flip_grid(bm) canvas_uv = lambda f, tu, tv: [((l.vert.co.y if abs(f.normal.x) > 0.5 else l.vert.co.x) / tu, l.vert.co.z / tv) for l in f.loops] for s in (-1, 1): both_sides(mb, lambda s=s: wall_side(s), ["canvas"], canvas_uv, ao_out=ao_ground) def gable(front): """Gable wall at y=-TD (front, with the door) or +TD; vertices shared so clamped rows collapse into triangles under the roof line.""" bm = bmesh.new() xs = [-TW, -1.05, -DOOR_W, -DOOR_W / 2, 0.0, DOOR_W / 2, DOOR_W, 1.05, TW] rows = [0.0, 0.6, EAVE, DOOR_H, 9.0] y = -TD if front else TD cache = {} def vert(i, z): x = xs[i] z = min(z, roof_z(x) - roof_sag(x, y) - 0.025) key = (i, round(z, 5)) if key not in cache: bulge = 0.03 * math.sin(math.pi * (x + TW) / (2 * TW)) * math.sin(math.pi * z / RIDGE) cache[key] = bm.verts.new((x, y + (bulge if front else -bulge), z)) return cache[key] for i in range(len(xs) - 1): for j in range(len(rows) - 1): xc = (xs[i] + xs[i + 1]) / 2 zc = (rows[j] + rows[j + 1]) / 2 if front and abs(xc) < DOOR_W and zc < DOOR_H: continue # doorway quad = [vert(i, rows[j]), vert(i + 1, rows[j]), vert(i + 1, rows[j + 1]), vert(i, rows[j + 1])] uniq = [] for q in quad: if q not in uniq: uniq.append(q) if len(uniq) >= 3: f = bm.faces.new(uniq if front else uniq[::-1]) # outward: -Y for the front return bm for front in (True, False): both_sides(mb, lambda front=front: gable(front), ["canvas"], canvas_uv, ao_out=ao_ground) # Valance strips under the eaves and along the gable edges (green trim). def valance_side(s): def fn(u, v): y = -Dy + 2 * Dy * u x = s * (TW + OVX) ztop = roof_z(x) scallop = 0.05 * abs(math.sin(u * math.pi * 9)) return Vector((x + s * 0.005, y, ztop - (0.17 - scallop) * (1 - v))) bm = bm_grid(fn, 36, 1) return bm if s > 0 else _flip_grid(bm) def valance_gable(front, s): y = -Dy if front else Dy def fn(u, v): x = s * u * (TW + OVX) scallop = 0.04 * abs(math.sin(u * math.pi * 6)) return Vector((x, y - (0.005 if front else -0.005), roof_z(x) - 0.01 - (0.14 - scallop) * (1 - v))) bm = bm_grid(fn, 24, 1) flip = (s > 0) != front return _flip_grid(bm) if flip else bm green_uv = lambda f, tu, tv: [((l.vert.co.x + l.vert.co.y) / tu, l.vert.co.z / tv) for l in f.loops] for s in (-1, 1): both_sides(mb, lambda s=s: valance_side(s), ["canvas_green"], green_uv, ao_out=ao_flat(0.95), ao_in=ao_flat(0.6)) for front in (True, False): both_sides(mb, lambda s=s, front=front: valance_gable(front, s), ["canvas_green"], green_uv, ao_out=ao_flat(0.95), ao_in=ao_flat(0.6)) # Door: green trim around the opening and the flap rolled up above it. yd = -TD - 0.012 for s in (-1, 1): box(mb, T(s * (DOOR_W + 0.03), yd, DOOR_H / 2), (0.07, 0.03, DOOR_H + 0.07), "canvas_green", rng, bevel=0.01) box(mb, T(0, yd, DOOR_H + 0.01), (2 * DOOR_W + 0.13, 0.03, 0.07), "canvas_green", rng, bevel=0.01, grain=0) roll = along((-DOOR_W - 0.08, yd - 0.07, DOOR_H + 0.1), (DOOR_W + 0.08, yd - 0.07, DOOR_H + 0.1)) bm = bm_lathe([(0.075, 0.0), (0.085, 0.3), (0.08, 2 * DOOR_W - 0.1), (0.075, 2 * DOOR_W + 0.16)], 12, bevel=0.02) emit(mb, bm, ["canvas_green", "canvas_green"], roll, uv_cyl(0.08), ao=ao_flat(0.9)) for s in (-1, 1): x = s * (DOOR_W - 0.12) torus(mb, T(x, yd - 0.07, DOOR_H + 0.1, ry=math.pi / 2), 0.088, 0.012, n_major=14) # Floor cloth inside. floor = lambda: bm_grid(lambda u, v: Vector((-TW + 2 * TW * u, -TD + 2 * TD * v, 0.012)), 1, 1) emit(mb, floor(), ["canvas_green"], T(), green_uv, ao=ao_flat(0.45)) # Something to look at inside: a supply crate and a lantern-lit corner. crate_parts(mb, T(-0.85, 0.75, 0.3, rz=0.2), 0.6, rng) AO[0] = ao_ground # Ridge pole poking out of both gables, with a pennant on the front end. log(mb, (0, -Dy - 0.14, RIDGE + 0.02), (0, Dy + 0.14, RIDGE + 0.02), 0.045, 0.045, rng, mat="wood", sides=8, noise=0.0, bevel=0.01) py = -Dy - 0.06 log(mb, (0, py, RIDGE), (0, py, RIDGE + 0.4), 0.02, 0.018, rng, mat="wood", sides=6, noise=0.0, bevel=0.006) torus(mb, T(0, py, RIDGE + 0.42), 0.026, 0.01, mat="wood", n_major=8, n_minor=4) for sgn in (1, -1): # pennant: two-sided triangle, fluttering tip base0, base1 = Vector((0.02, py, RIDGE + 0.39)), Vector((0.02, py, RIDGE + 0.22)) tip = Vector((0.36, py, RIDGE + 0.31)) pts = [base1, tip, base0] if sgn > 0 else [base0, tip, base1] n = Vector((0, -sgn, 0)) mb.face(pts, [(0, 0), (1, 0.5), (0, 1)] if sgn > 0 else [(0, 1), (1, 0.5), (0, 0)], [(1.0, 1.0, 1.0, 0.0), (1.0, 1.0, 1.0, 0.8), (1.0, 1.0, 1.0, 0.0)], [n] * 3, "canvas_green") # Guy ropes from the eaves to pegs. for s in (-1, 1): for y in (-0.85, 0.85): a = Vector((s * (TW + OVX - 0.03), y, roof_z(TW + OVX) - 0.02)) peg = Vector((s * (TW + 0.62), y, 0.08)) rope(mb, a, peg, r=0.012) beam(mb, (peg.x - s * 0.03, y, -0.06), (peg.x + s * 0.03, y, 0.14), 0.045, 0.045, "wood", rng, bevel=0.01) return mb.build(name) def _flip_grid(bm): bmesh.ops.reverse_faces(bm, faces=bm.faces[:]) return bm # ------------------------------------------------------------------- main -- def export(objects, path): """One .glb with every model as a named root node, so textures are stored once. Child empties are exported too: the engine reads them as named sockets.""" bpy.ops.object.select_all(action="DESELECT") for ob in objects: ob.select_set(True) for child in ob.children: child.select_set(True) bpy.context.view_layer.objects.active = objects[0] bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True, export_yup=True, export_apply=True, export_normals=True, export_texcoords=True, export_vertex_color="ACTIVE", export_image_format="AUTO", export_materials="EXPORT") def gltf(v): """Blender Z-up -> glTF Y-up.""" return (v[0], v[2], -v[1]) def report(ob): pts = [gltf(v.co) for v in ob.data.vertices] lo = [min(p[i] for p in pts) for i in range(3)] hi = [max(p[i] for p in pts) for i in range(3)] print(f" {ob.name:<11} {len(ob.data.polygons):5d} faces min ({lo[0]:+.3f}, {lo[1]:+.3f}, {lo[2]:+.3f})" f" max ({hi[0]:+.3f}, {hi[1]:+.3f}, {hi[2]:+.3f})") for c in ob.children: p = gltf(c.location) print(f" socket {c.name:<8} ({p[0]:+.3f}, {p[1]:+.3f}, {p[2]:+.3f})") MODELS = [ ("fence", make_fence, 1), ("fence_post", make_fence_post, 2), ("gate", make_gate, 3), ("signpost", make_signpost, 4), ("lamp", make_lamp, 5), ("dock", make_dock, 6), ("dock_end", make_dock_end, 7), ("tent", make_tent, 8), ("crate", make_crate, 9), ("barrel", make_barrel, 10), ("log_bench", make_log_bench, 11), ("hay_bale", make_hay_bale, 12), ] # Everything lives in its own collection; nothing else in the open file is touched. COLLECTION = bpy.data.collections.get("Safari_Props") if COLLECTION is None: COLLECTION = bpy.data.collections.new("Safari_Props") bpy.context.scene.collection.children.link(COLLECTION) for ob in list(COLLECTION.objects): data = ob.data bpy.data.objects.remove(ob, do_unlink=True) if isinstance(data, bpy.types.Mesh) and data.users == 0: bpy.data.meshes.remove(data) MAT = {n: material(n, n + ".png") for n in TEXEL if n != "lantern_glass"} MAT["lantern_glass"] = material("lantern_glass", "lantern_glass.png", emission=3.0, roughness=0.3) os.makedirs(OUT, exist_ok=True) objects = [] for name, fn, seed in MODELS: AO[0] = ao_ground objects.append(fn(name, seed)) glb = os.path.join(OUT, "safari.glb") export(objects, glb) # all at the origin: the engine looks models up by node name print("wrote", glb) for ob in objects: report(ob) # ---------------------------------------------------------------- preview -- LAYOUT = { # Blender (x, y, z) for the side-by-side lineup; docks stand in the preview water "gate": (-5.4, 3.6, 0), "tent": (-0.6, 3.8, 0), "lamp": (2.6, 3.2, 0), "signpost": (3.9, 3.0, 0), "dock": (-6.4, -0.6, 1.3), "dock_end": (-4.4, -0.6, 1.3), "fence": (-1.9, -0.5, 0), "fence_post": (-0.3, -0.5, 0), "crate": (0.8, -0.5, 0), "barrel": (1.9, -0.5, 0), "log_bench": (3.3, -0.6, 0), "hay_bale": (5.0, -0.5, 0), } REF_LAYOUT = {"oak_a": (6.8, 3.6, 0), "rock_a": (6.5, -0.5, 0), "bush_a": (7.6, -0.4, 0)} def preview(path, reference=None): """Renders the lineup in a throwaway scene that only contains Safari_Props, so nothing else in the open file shows up (or is modified).""" old = bpy.data.scenes.get("Safari_Preview") if old: bpy.data.scenes.remove(old) scene = bpy.data.scenes.new("Safari_Preview") scene.collection.children.link(COLLECTION) for ob in objects: ob.location = LAYOUT.get(ob.name, (0, 0, 0)) if reference: # engine scenery for a side-by-side style check before = set(bpy.data.objects) bpy.ops.import_scene.gltf(filepath=reference) for ob in set(bpy.data.objects) - before: for c in ob.users_collection: c.objects.unlink(ob) COLLECTION.objects.link(ob) if ob.parent is None and ob.name in REF_LAYOUT: ob.location = REF_LAYOUT[ob.name] elif ob.parent is None: ob.hide_render = True ground = bpy.data.meshes.new("preview_ground") ground.from_pydata([(-30, -30, 0), (30, -30, 0), (30, 30, 0), (-30, 30, 0)], [], [(0, 1, 2, 3)]) gob = bpy.data.objects.new("preview_ground", ground) gm = bpy.data.materials.get("preview_ground") or bpy.data.materials.new("preview_ground") gm.use_nodes = True next(n for n in gm.node_tree.nodes if n.type == "BSDF_PRINCIPLED").inputs["Base Color"].default_value = \ (0.2, 0.32, 0.13, 1) ground.materials.append(gm) COLLECTION.objects.link(gob) # Water box under the docks so their legs read as standing in water. water = bpy.data.meshes.new("preview_water") water.from_pydata([(-7.6, -1.9, 0.45), (-3.2, -1.9, 0.45), (-3.2, 0.7, 0.45), (-7.6, 0.7, 0.45)], [], [(0, 1, 2, 3)]) wob = bpy.data.objects.new("preview_water", water) wm = bpy.data.materials.get("preview_water") or bpy.data.materials.new("preview_water") wm.use_nodes = True bsdf = next(n for n in wm.node_tree.nodes if n.type == "BSDF_PRINCIPLED") bsdf.inputs["Base Color"].default_value = (0.1, 0.3, 0.38, 1) bsdf.inputs["Roughness"].default_value = 0.15 water.materials.append(wm) COLLECTION.objects.link(wob) cam = bpy.data.objects.new("preview_cam", bpy.data.cameras.new("preview_cam")) focus = arg("--focus") if focus: # close-up of one model ob = bpy.data.objects[focus] lo = Vector([min(c[i] for c in ob.bound_box) for i in range(3)]) hi = Vector([max(c[i] for c in ob.bound_box) for i in range(3)]) target = ob.location + (lo + hi) / 2 dist = max((hi - lo).length * 1.6, 2.2) cam.data.lens = 50 pitch = math.radians(float(arg("--pitch", 58))) yaw = math.radians(float(arg("--yaw", 0))) else: # HD-2D-ish three-quarter view of the whole lineup target, dist, pitch, yaw = Vector((0.3, 1.5, 0.8)), 17.5, math.radians(52), 0.0 cam.data.lens = 35 cam.location = target + Vector((math.sin(yaw) * math.sin(pitch), -math.cos(yaw) * math.sin(pitch), math.cos(pitch))) * dist cam.rotation_euler = (pitch, 0, yaw) COLLECTION.objects.link(cam) sun = bpy.data.objects.new("preview_sun", bpy.data.lights.new("preview_sun", "SUN")) sun.data.energy = 3.5 sun.data.angle = math.radians(8) sun.rotation_euler = (math.radians(45), math.radians(-20), math.radians(-30)) COLLECTION.objects.link(sun) world = bpy.data.worlds.get("preview_world") or bpy.data.worlds.new("preview_world") world.use_nodes = True bg = next(n for n in world.node_tree.nodes if n.type == "BACKGROUND") bg.inputs["Color"].default_value = (0.55, 0.62, 0.7, 1) bg.inputs["Strength"].default_value = 0.9 scene.world = world scene.camera = cam try: scene.render.engine = "BLENDER_EEVEE" except TypeError: scene.render.engine = "BLENDER_WORKBENCH" scene.render.resolution_x, scene.render.resolution_y = int(arg("--width", 1800)), int(arg("--height", 1000)) scene.render.resolution_percentage = 100 scene.render.filepath = path bpy.ops.render.render(write_still=True, scene=scene.name) bpy.data.scenes.remove(scene) print("preview", path) if arg("--preview"): preview(arg("--preview"), arg("--reference")) else: for ob in objects: # lay out side by side for inspection in a live session ob.location = LAYOUT.get(ob.name, (0, 0, 0))