- 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
1100 lines
48 KiB
Python
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))
|