Files
brandling-safari/tools/blender_safari.py
Gabe 238cd9c6c3 Brandling Safari: LAN safari-zone creature catching on the HD-2D engine
- Authoritative 30 Hz server (ENet, SQLite): accounts, characters, wild AI, tall grass /
  water / wandering spawns, fishing, captures, follower chains, clock + weather, LAN beacon
- Client: title / join / character creator / play screens, prediction + reconciliation,
  interpolated players and parties, HD-2D UI (dialogue with Professor Henry, HUD, collection)
- Shared deterministic zone generator, movement, protocol, roster, appearance
- Paper-doll character art pipeline (PixelLab base walk + per-item animation edits)
- Brandling roster from the company list (logo colors, jev types), Blender safari props
- Headless test bot
2026-10-02 15:20:27 -05:00

1100 lines
48 KiB
Python

"""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 <engine>/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))