Files
2026-09-05 22:54:54 +02:00

534 lines
26 KiB
Python

"""Blender 5.1: sculpt the modular woodland traveler kit and editable preview.
Run Blender --background --python tools/art/build_character_kit.py.
Coordinates are Godot Y-up, facing +Z. Every mesh has an explicit socket origin.
UV2.x carries a palette role; vertex color is the original Blender preview paint.
The Stout shape key changes the lower torso, including clothing and bag straps.
"""
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "art/characters"
OUTPUT = ROOT / "assets/characters"
SOURCE.mkdir(parents=True, exist_ok=True)
OUTPUT.mkdir(parents=True, exist_ok=True)
(SOURCE / ".gdignore").write_text("")
PALETTE = {
"fur": (1, "c78a4e"), "muzzle": (2, "eee4d1"),
"coat": (3, "496a9d"), "linen": (4, "efe6d4"),
"scarf": (5, "7295bf"), "trousers": (6, "7995b6"),
"leather": (7, "6f4937"), "fur_shadow": (8, "805032"),
"coat_shadow": (9, "304766"), "ink": (0, "3e352d"),
"pink": (0, "bd8674"), "brass": (0, "b99c70"),
"ivory": (0, "f9f1dc"), "iris": (0, "6d8999"),
}
PIECES = {}
LIBRARY = {}
PIVOTS = {}
def coord(point):
return Vector((point[0], -point[2], point[1]))
def linear(hex_color):
channels = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)]
return tuple(v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels)
def paint(obj, part, role):
role_id, color = PALETTE[role]
rgb = linear(color)
attr = obj.data.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="CORNER")
obj.data.uv_layers.new(name="UVMap")
role_uv = obj.data.uv_layers.new(name="PaletteRole")
for poly in obj.data.polygons:
poly.use_smooth = True
for index in poly.loop_indices:
attr.data[index].color = (*rgb, 1.0)
role_uv.data[index].uv = (role_id, 0)
obj.data.materials.append(bpy.data.materials["TravelerPalette"])
PIECES.setdefault(part, []).append(obj)
return obj
def mesh_object(part, vertices, faces, role):
mesh = bpy.data.meshes.new(part + "_surface")
mesh.from_pydata([coord(v) for v in vertices], [], faces)
mesh.update()
obj = bpy.data.objects.new(part + "_detail", mesh)
bpy.context.collection.objects.link(obj)
return paint(obj, part, role)
def ellipsoid(part, center, radius, role, segments=20, rings=12):
bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, location=coord(center))
obj = bpy.context.object
obj.scale = (radius[0], radius[2], radius[1])
return paint(obj, part, role)
def rounded_box(part, center, size, radius, role, tilt=0):
bpy.ops.mesh.primitive_cube_add(size=1, location=coord(center))
obj = bpy.context.object
obj.scale = (size[0], size[2], size[1])
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
bevel = obj.modifiers.new("Tailored rounded corners", "BEVEL")
bevel.width = radius
bevel.segments = 3
bpy.ops.object.modifier_apply(modifier=bevel.name)
obj.rotation_euler.z = math.radians(tilt)
return paint(obj, part, role)
def tube(part, points, radii, role, sides=8, flatten=1.0):
verts, faces = [], []
for i, point in enumerate(points):
p = Vector(point)
tangent = Vector(points[min(i + 1, len(points) - 1)]) - Vector(points[max(0, i - 1)])
tangent.normalize()
side = tangent.cross(Vector((0, 0, 1)))
if side.length < 0.01:
side = tangent.cross(Vector((1, 0, 0)))
side.normalize()
other = tangent.cross(side).normalized()
radius = radii[i] if isinstance(radii, (list, tuple)) else radii
for j in range(sides):
a = math.tau * j / sides
verts.append(p + radius * (math.cos(a) * side + math.sin(a) * other * flatten))
if i:
for j in range(sides):
a, b = (i - 1) * sides + j, (i - 1) * sides + (j + 1) % sides
faces.append((a, b, b + sides, a + sides))
faces.extend([tuple(reversed(range(sides))), tuple(range(len(verts) - sides, len(verts)))])
return mesh_object(part, verts, faces, role)
def loft(part, rows, role, segments=28, start=0, span=math.tau):
"""Tailored cross sections: (height, half width, half depth, center x, center z)."""
verts, faces = [], []
closed = abs(span - math.tau) < 0.001
count = segments if closed else segments + 1
for y, rx, rz, cx, cz in rows:
for j in range(count):
angle = start + span * j / segments
verts.append((cx + rx * math.cos(angle), y, cz + rz * math.sin(angle)))
for row in range(len(rows) - 1):
for j in range(segments):
a, b = row * count + j, row * count + (j + 1) % count
faces.append((a, a + count, b + count, b))
if closed:
faces.extend([tuple(range(count)), tuple(reversed(range(len(verts) - count, len(verts))))])
return mesh_object(part, verts, faces, role)
def finish_part(name, pivot, stout=False):
pieces = PIECES.pop(name)
bpy.ops.object.select_all(action="DESELECT")
for obj in pieces:
obj.select_set(True)
bpy.context.view_layer.objects.active = pieces[0]
bpy.ops.object.join()
obj = bpy.context.object
obj.name = name
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
bpy.context.scene.cursor.location = coord(pivot)
bpy.ops.object.origin_set(type="ORIGIN_CURSOR")
obj.data.materials.clear()
obj.data.materials.append(bpy.data.materials["TravelerPalette"])
for face in obj.data.polygons:
face.material_index = 0
if stout:
obj.shape_key_add(name="Basis")
key = obj.shape_key_add(name="Stout")
for point in key.data:
world_y = point.co.z + pivot[1]
belly = math.exp(-((world_y - 0.97) / 0.42) ** 2)
world_x = point.co.x + pivot[0]
world_z = -point.co.y + pivot[2]
point.co.x += world_x * (0.13 + 0.32 * belly)
point.co.y -= world_z * (0.14 + 0.42 * belly)
key.value = 0.0
obj.data.calc_loop_triangles()
LIBRARY[name] = obj
PIVOTS[name] = pivot
return obj
def torso(style):
part = "Torso" + style
rows = [(0.66, .32, .24, 0, 0), (.75, .39, .29, 0, 0),
(.90, .405, .31, 0, .01), (1.10, .385, .30, 0, .01),
(1.30, .325, .26, 0, 0), (1.43, .27, .20, 0, 0), (1.51, .18, .15, 0, 0)]
loft(part, rows, "linen")
loft(part, [(1.44, .17, .14, 0, 0), (1.63, .18, .15, 0, 0),
(1.73, .21, .16, 0, 0)], "fur", 24)
for y, z in [(1.03, .325), (1.15, .310), (1.27, .279)]:
ellipsoid(part, (0, y, z), (.014, .014, .011), "leather", 10, 6)
tube(part, [(-.043, .94, .327), (-.043, 1.14, .309), (-.043, 1.36, .235)], .004, "muzzle", 5)
outer = [(y, rx + .025, rz + .022, cx, cz) for y, rx, rz, cx, cz in rows]
if style == "Coat":
outer = [(0.45, .355, .26, 0, -.01), (.57, .415, .30, 0, -.01)] + outer
if style == "Tunic":
loft(part, [(y, rx + .018, rz + .018, cx, cz) for y, rx, rz, cx, cz in rows[:-1]], "coat")
rounded_box(part, (0, .91, .365), (.40, .42, .028), .025, "linen")
tube(part, [(-.19, 1.13, .315), (0, 1.15, .338), (.19, 1.13, .315)], .012, "coat_shadow", 6)
else:
gap = .31
loft(part, outer, "coat", start=math.pi / 2 + gap, span=math.tau - 2 * gap)
for sign in (-1, 1):
edge = [(sign * rx * math.sin(gap), y, cz + rz * math.cos(gap)) for y, rx, rz, cx, cz in outer]
tube(part, edge, .012, "coat_shadow", 6)
rounded_box(part, (sign * .275, .835, .272), (.19, .17, .039), .027, "coat", tilt=sign * 17)
tube(part, [(sign * .18, .91, .315), (sign * .27, .914, .308), (sign * .36, .907, .259)], .006, "coat_shadow", 5)
for y, z in [(.79, .310), (.97, .331), (1.16, .309)]:
ellipsoid(part, (.15, y, z), (.025, .026, .013), "ivory", 12, 8)
ellipsoid(part, (.15, y, z + .012), (.009, .008, .004), "coat_shadow", 8, 4)
# A stitched waistband and buckle sit over the trouser waist.
loft(part, [(.68, .343, .252, 0, .0), (.727, .369, .277, 0, .0)], "leather")
rounded_box(part, (0, .703, .291), (.115, .060, .022), .010, "brass")
rounded_box(part, (0, .703, .307), (.076, .035, .008), .004, "leather")
for sign in (-1, 1):
mesh_object(part, [(sign * .05, 1.47, .17), (sign * .20, 1.46, .17),
(sign * .24, 1.32, .235), (sign * .11, 1.38, .25)],
[(0, 1, 2, 3)] if sign > 0 else [(3, 2, 1, 0)], "linen")
finish_part(part, (0, 1.05, 0), True)
def arm(side, bent):
sign = -1 if side == "Left" else 1
x = sign * .44
name = "Arm" + ("Hiker" if bent else "Relaxed") + side
if bent:
points = [(x, 1.35, 0), (x + sign * .12, 1.18, .07),
(x + sign * .10, 1.05, .20), (x - sign * .035, 1.14, .32)]
hand = (x - sign * .055, 1.20, .33)
tube(name, points, [.135, .17, .165, .12], "linen", 18)
ellipsoid(name, points[0], (.14, .145, .14), "linen", 20, 12)
ellipsoid(name, points[2], (.165, .14, .15), "linen")
ellipsoid(name, points[-1], (.135, .082, .13), "muzzle")
else:
loft(name, [(y, rx, rz, x + sign * offset, z) for y, rx, rz, offset, z in [
(.78, .12, .13, .025, .06), (.88, .135, .15, .035, .03),
(1.03, .17, .16, .045, .005), (1.21, .16, .145, .025, 0),
(1.35, .12, .12, 0, 0), (1.41, .055, .07, -.025, 0)]], "linen", 20)
loft(name, [(.78, .133, .141, x + sign * .025, .06),
(.84, .143, .15, x + sign * .028, .052)], "muzzle", 20)
hand = (x + sign * .03, .694, .065)
ellipsoid(name, hand, (.12, .12, .105), "fur")
ellipsoid(name, (hand[0] - sign * .105, hand[1] + .023, hand[2] + .035), (.051, .066, .062), "fur", 14, 8)
for index in (-1, 0, 1):
tube(name, [(hand[0] + index * .042, hand[1] - .041, hand[2] + .096),
(hand[0] + index * .042, hand[1] - .070, hand[2] + .08)], .004, "fur_shadow", 5)
finish_part(name, (x, 1.30, 0))
def leg(side):
x = -.20 if side == "Left" else .20
name = "Leg" + side
loft(name, [(.20, .115, .13, x, .01), (.32, .157, .15, x, 0),
(.43, .17, .16, x, -.01), (.59, .18, .17, x, -.01),
(.75, .18, .165, x, 0)], "trousers", 20)
tube(name, [(x + .15, .35, .06), (x + .16, .47, .07), (x + .15, .66, .07)], .005, "coat_shadow", 5)
rounded_box(name, (x, .035, .095), (.335, .056, .57), .025, "ink")
ellipsoid(name, (x, .129, .12), (.167, .105, .284), "leather")
loft(name, [(.13, .142, .15, x, -.01), (.24, .137, .136, x, -.03),
(.33, .155, .143, x, -.03)], "leather", 20)
loft(name, [(.294, .163, .151, x, -.03), (.34, .161, .148, x, -.03)], "fur_shadow", 20)
tube(name, [(x - .075, .21, .24), (x, .228, .26), (x + .075, .21, .24)], .008, "brass", 6)
finish_part(name, (x, .61, 0))
def head(species):
name = "Head" + species
width = {"Cat": .49, "Fox": .45, "Rabbit": .445, "Badger": .51, "Otter": .485}[species]
depth = .34
verts, faces = [], []
rings, segments = 20, 40
for row in range(rings + 1):
phi = math.pi * row / rings
vertical = math.cos(phi)
cheek = 1.0 + .14 * math.exp(-((vertical + .22) / .49) ** 2)
for j in range(segments):
angle = math.tau * j / segments
verts.append((width * math.sin(phi) * math.cos(angle) * cheek,
1.93 + .435 * vertical,
depth * math.sin(phi) * math.sin(angle)))
for row in range(rings):
for j in range(segments):
a, b = row * segments + j, row * segments + (j + 1) % segments
faces.append((b, b + segments, a + segments, a))
obj = mesh_object(name, verts, faces, "fur")
colors = obj.data.color_attributes["Color"]
uv = obj.data.uv_layers["PaletteRole"]
for polygon in obj.data.polygons:
center = sum((Vector(verts[i]) for i in polygon.vertices), Vector()) / len(polygon.vertices)
role = "fur"
if center.z > .03 and center.y < 1.985 - .18 * math.exp(-(center.x / .15) ** 2):
role = "muzzle"
if species == "Badger" and center.z > .08 and .105 < abs(center.x) < .32 and center.y > 1.80:
role = "fur_shadow"
for index in polygon.loop_indices:
colors.data[index].color = (*linear(PALETTE[role][1]), 1)
uv.data[index].uv = (PALETTE[role][0], 0)
muzzle_depth = .20 if species == "Fox" else .11
muzzle_z = .335 if species == "Fox" else .315
for sign in (-1, 1):
ellipsoid(name, (sign * .105, 1.843, muzzle_z), (.15, .105, muzzle_depth), "muzzle", 20, 12)
nose_z = muzzle_z + muzzle_depth * .91
rounded_box(name, (0, 1.927, nose_z), (.096, .054, .055), .016, "fur_shadow" if species != "Rabbit" else "pink")
tube(name, [(0, 1.90, nose_z + .024), (0, 1.854, nose_z + .007)], .007, "ink", 6)
for sign in (-1, 1):
tube(name, [(0, 1.854, nose_z + .007), (sign * .065, 1.825, nose_z - .005),
(sign * .135, 1.839, nose_z - .040), (sign * .17, 1.877, nose_z - .064)], [.006, .007, .006, .002], "ink", 6)
eye_x = sign * .213
# Almond eyes with lids and a visible iris, seated into the face.
ellipsoid(name, (eye_x, 2.077, .301), (.109, .055, .043), "fur_shadow", 24, 12)
ellipsoid(name, (eye_x, 2.075, .322), (.098, .041, .030), "ivory", 24, 12)
ellipsoid(name, (eye_x - sign * .011, 2.075, .350), (.035, .039, .011), "iris", 16, 10)
ellipsoid(name, (eye_x - sign * .011, 2.076, .359), (.016, .031, .008), "ink", 12, 8)
ellipsoid(name, (eye_x - sign * .022, 2.094, .365), (.009, .011, .005), "ivory", 8, 6)
tube(name, [(eye_x - .101, 2.076, .318), (eye_x - .04, 2.105, .35),
(eye_x + .035, 2.102, .355), (eye_x + .102, 2.073, .312)], [.005, .012, .012, .003], "fur_shadow", 7)
tube(name, [(eye_x - .079, 2.226 - sign * .013, .27),
(eye_x, 2.242, .288), (eye_x + .067, 2.213 + sign * .007, .259)], [.009, .020, .007], "fur_shadow", 8)
for j in range(3):
y = 1.86 + j * .033
tube(name, [(sign * .26, y, .285), (sign * .47, y + (j - 1) * .025, .28),
(sign * (.64 - j * .015), y + (j - 1) * .055, .24)], [.005, .0035, .001], "fur_shadow", 5)
for x, y in [(0.20, 1.92), (.265, 1.88), (.22, 1.84)]:
ellipsoid(name, (sign * x, y, .339), (.007, .007, .005), "fur_shadow", 8, 4)
if species in ("Cat", "Fox"):
for sign in (-1, 0, 1):
tube(name, [(sign * .115, 2.327, .10), (sign * .112, 2.296, .185),
(sign * .09, 2.248, .248)], [.019, .024, .003], "fur_shadow", 7)
# Small swept fur tufts keep the head silhouette organic.
for sign in (-1, 1):
tube(name, [(sign * .41, 1.88, .06), (sign * .53, 1.91, .04),
(sign * .57, 1.94, .02)], [.075, .035, .001], "fur", 8)
finish_part(name, (0, 1.68, 0))
def ears(style):
name = "Ears" + style
for sign in (-1, 1):
if style in ("Cat", "Fox"):
height = .38 if style == "Cat" else .46
x = sign * .34
tube(name, [(x, 2.205, -.02), (x + sign * .05, 2.38, -.025),
(x + sign * .092, 2.205 + height, -.04)], [.165, .105, .004], "fur", 12, .43)
mesh_object(name, [(x - sign * .104, 2.22, .082), (x + sign * .127, 2.245, .070),
(x + sign * .081, 2.205 + height - .051, -.015)],
[(0, 1, 2)] if sign > 0 else [(2, 1, 0)], "muzzle")
tube(name, [(x, 2.25, .105), (x + sign * .029, 2.31, .075),
(x + sign * .053, 2.365, .035)], [.028, .020, .002], "pink", 6)
elif style in ("Rabbit", "Lop"):
x = sign * .25
points = [(x, 2.23, -.025), (x + sign * .02, 2.45, -.04),
(x + sign * .07, 2.73, -.09), (x + sign * .10, 2.87, -.12)]
if style == "Lop":
points = [(x, 2.23, -.025), (x + sign * .12, 2.49, -.04),
(x + sign * .25, 2.51, -.03), (x + sign * .31, 2.24, .015)]
tube(name, points, [.105, .123, .103, .008], "fur", 14)
tube(name, [(p[0], p[1], p[2] + .085) for p in points[1:]], [.060, .055, .006], "pink", 10)
else:
x = sign * .39
radius = .145 if style == "Badger" else .12
ellipsoid(name, (x, 2.265, -.015), (radius, radius * 1.05, .09), "fur", 20, 12)
ellipsoid(name, (x, 2.27, .066), (radius * .62, radius * .63, .02), "pink", 16, 10)
finish_part(name, (0, 2.20, 0))
def tail(species):
name = "Tail" + species
if species == "Rabbit":
ellipsoid(name, (0, .70, -.38), (.19, .19, .18), "muzzle")
else:
points = [(0, .80, -.22), (.17, .58, -.47), (.43, .62, -.62),
(.66, .91, -.61), (.71, 1.21, -.48), (.63, 1.43, -.37)]
radii = [.065, .09, .125, .14, .12, .002]
if species == "Fox":
radii = [.095, .17, .24, .25, .19, .002]
if species == "Badger":
points, radii = [(0, .8, -.2), (.08, .56, -.43), (.15, .52, -.63)], [.07, .14, .003]
if species == "Otter":
points, radii = [(0, .8, -.2), (.05, .48, -.51), (.29, .24, -.80), (.51, .23, -.83)], [.13, .14, .10, .003]
# Subdivide the sweep with Catmull-Rom interpolation for a soft, curling silhouette.
smooth_points, smooth_radii = [], []
for i in range(len(points) - 1):
p0, p1 = Vector(points[max(i - 1, 0)]), Vector(points[i])
p2, p3 = Vector(points[i + 1]), Vector(points[min(i + 2, len(points) - 1)])
for step in range(4):
t = step / 4
smooth_points.append(.5 * ((2 * p1) + (-p0 + p2) * t +
(2*p0 - 5*p1 + 4*p2 - p3)*t*t + (-p0 + 3*p1 - 3*p2 + p3)*t*t*t))
smooth_radii.append(radii[i] * (1 - t) + radii[i + 1] * t)
smooth_points.append(Vector(points[-1]))
smooth_radii.append(radii[-1])
tube(name, smooth_points, smooth_radii, "fur", 14)
if species == "Fox":
tube(name, points[-2:], [radii[-2] * 1.01, .002], "muzzle", 14)
finish_part(name, (0, .8, -.22))
def accessories():
name = "Scarf"
tube(name, [(-.19, 1.48, -.1), (-.23, 1.43, .06), (-.15, 1.41, .19),
(0, 1.395, .24), (.18, 1.43, .13), (.20, 1.48, -.10)], .072, "scarf", 12)
rounded_box(name, (-.17, 1.31, .269), (.145, .21, .062), .032, "scarf", tilt=-12)
mesh_object(name, [(-.22, 1.35, .30), (-.10, 1.32, .32), (-.11, .68, .35),
(-.20, .74, .355), (-.25, .67, .347), (-.27, 1.09, .35)], [(0, 5, 4, 3, 2, 1)], "scarf")
for x in (-.23, -.20, -.17, -.14):
tube(name, [(x, .73, .35), (x - .01, .62, .36)], [.008, .003], "scarf", 5)
finish_part(name, (0, 1.05, 0), True)
name = "BagTravel"
rounded_box(name, (0, 1.24, -.46), (.67, 1.04, .39), .12, "leather")
rounded_box(name, (0, 1.63, -.58), (.72, .32, .25), .085, "fur_shadow")
for sign in (-1, 1):
tube(name, [(sign * .23, 1.71, -.52), (sign * .29, 1.54, -.18),
(sign * .275, 1.36, .20), (sign * .32, 1.05, .292),
(sign * .37, .81, .15), (sign * .29, .76, -.37)], .032, "leather", 8)
rounded_box(name, (sign * .27, 1.16, .311), (.081, .10, .031), .014, "brass")
rounded_box(name, (sign * .345, 1.10, -.44), (.145, .35, .255), .040, "leather")
tube(name, [(sign * .21, .85, -.672), (sign * .21, 1.3, -.677),
(sign * .21, 1.67, -.714)], .021, "fur_shadow", 7)
tube(name, [(-.43, 1.88, -.50), (.43, 1.88, -.50)], .215, "scarf", 28)
for sign in (-1, 1):
points = [(sign * .434, 1.88 + .18 * (1 - t / 34) * math.sin(t * .42),
-.50 + .18 * (1 - t / 34) * math.cos(t * .42)) for t in range(33)]
tube(name, points, .008, "coat_shadow", 5)
ring = [(sign * .27, 1.88 + .223 * math.sin(t * math.tau / 24),
-.50 + .223 * math.cos(t * math.tau / 24)) for t in range(25)]
tube(name, ring, .018, "leather", 6)
finish_part(name, (0, 1.05, 0), True)
name = "BagSatchel"
rounded_box(name, (.46, .80, .045), (.28, .35, .25), .07, "leather")
rounded_box(name, (.47, .90, .16), (.29, .19, .07), .025, "fur_shadow")
rounded_box(name, (.48, .85, .206), (.052, .076, .013), .008, "brass")
tube(name, [(-.27, 1.46, -.05), (-.23, 1.40, .20), (.03, 1.15, .33),
(.30, .90, .30), (.46, .88, .03)], .027, "leather", 8)
finish_part(name, (0, 1.05, 0), True)
name = "HatBeret"
ellipsoid(name, (.035, 2.32, -.02), (.44, .155, .325), "coat", 28, 14)
loft(name, [(2.255, .385, .277, 0, -.02), (2.31, .392, .285, 0, -.02)], "coat_shadow")
tube(name, [(0, 2.445, -.01), (.027, 2.48, -.012), (.051, 2.479, -.025)], .021, "coat_shadow")
finish_part(name, (0, 1.9, 0))
name = "HatBrim"
ellipsoid(name, (0, 2.285, -.025), (.60, .045, .415), "leather", 36, 10)
loft(name, [(2.29, .39, .28, 0, -.025), (2.43, .365, .26, 0, -.035),
(2.55, .28, .215, -.035, -.035), (2.59, .21, .17, -.04, -.035)], "leather")
loft(name, [(2.31, .396, .285, 0, -.025), (2.367, .386, .277, 0, -.03)], "scarf")
finish_part(name, (0, 1.9, 0))
name = "Spectacles"
for sign in (-1, 1):
points = [(sign * .213 + .127 * math.cos(t * math.tau / 28),
2.073 + .099 * math.sin(t * math.tau / 28), .375) for t in range(29)]
tube(name, points, .008, "brass", 5)
tube(name, [(sign * .335, 2.08, .375), (sign * .46, 2.10, .16)], .008, "leather", 6)
tube(name, [(-.086, 2.081, .375), (0, 2.109, .402), (.086, 2.081, .375)], .009, "brass", 6)
finish_part(name, (0, 1.68, 0))
def export_and_preview():
bpy.ops.object.select_all(action="DESELECT")
for obj in LIBRARY.values():
obj.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(OUTPUT / "woodland_parts.glb"), export_format="GLB",
use_selection=True, export_yup=True, export_animations=False,
export_morph=True, export_morph_normal=True,
export_vertex_color="ACTIVE", export_materials="EXPORT",
export_cameras=False, export_extras=False)
budget = {}
for name, obj in LIBRARY.items():
budget[name] = {"triangles": len(obj.data.loop_triangles), "surfaces": 1,
"pivot": PIVOTS[name], "stout_shape": obj.data.shape_keys is not None}
(OUTPUT / "mesh_budget.json").write_text(json.dumps(budget, indent=2) + "\n")
# The saved file opens on the reference traveler; the complete kit stays editable.
preview = bpy.data.collections.new("01 Reference traveler - assembled example")
bpy.context.scene.collection.children.link(preview)
selected = ["TorsoVest", "HeadCat", "EarsCat", "ArmHikerLeft", "ArmHikerRight",
"LegLeft", "LegRight", "TailCat", "Scarf", "BagTravel"]
for name, source in LIBRARY.items():
source.hide_render = True
source.hide_set(True)
if name not in selected:
continue
copy = source.copy()
copy.data = source.data.copy()
copy.name = "Preview_" + name
preview.objects.link(copy)
copy.hide_render = False
copy.hide_set(False)
if copy.data.shape_keys:
copy.data.shape_keys.key_blocks["Stout"].value = .68
if name.startswith("Arm"):
copy.location.x += (-1 if "Left" in name else 1) * .068
copy.scale.x = 1.1
if name.startswith("Leg"):
copy.location.x += (-1 if "Left" in name else 1) * .034
copy.scale.x = 1.14
scene = bpy.context.scene
scene.world.color = (.32, .36, .40)
scene.render.engine = "CYCLES"
scene.cycles.samples = 32
for position, energy, size in [((3, 5, 4), 450, 5), ((-3, 3, 1), 220, 4), ((1, 4, -3), 500, 3)]:
bpy.ops.object.light_add(type="AREA", location=coord(position))
light = bpy.context.object
light.data.energy = energy
light.data.shape = "DISK"
light.data.size = size
light.rotation_euler = (coord((0, 1.25, 0)) - light.location).to_track_quat('-Z', 'Y').to_euler()
bpy.ops.object.camera_add(location=coord((3.2, 2.6, 5.8)))
camera = bpy.context.object
camera.rotation_euler = (coord((0, 1.3, 0)) - camera.location).to_track_quat('-Z', 'Y').to_euler()
camera.data.type = "ORTHO"
camera.data.ortho_scale = 3.6
scene.camera = camera
scene.render.resolution_x = 1000
scene.render.resolution_y = 1100
scene.render.resolution_percentage = 100
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
area.spaces.active.shading.type = "MATERIAL"
area.spaces.active.region_3d.view_distance = 4.0
area.spaces.active.region_3d.view_location = coord((0, 1.3, 0))
bpy.context.preferences.filepaths.save_version = 0
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "woodland_character_kit.blend"), compress=True)
print("CHARACTER KIT", len(LIBRARY), "parts", sum(v["triangles"] for v in budget.values()), "library triangles")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
material = bpy.data.materials.new("TravelerPalette")
material.use_nodes = True
tree = material.node_tree
shader = tree.nodes.get("Principled BSDF")
shader.inputs["Roughness"].default_value = .9
vertex = tree.nodes.new("ShaderNodeVertexColor")
vertex.layer_name = "Color"
tree.links.new(vertex.outputs["Color"], shader.inputs["Base Color"])
for style in ("Vest", "Coat", "Tunic"):
torso(style)
for side in ("Left", "Right"):
arm(side, False)
arm(side, True)
leg(side)
for species in ("Cat", "Fox", "Rabbit", "Badger", "Otter"):
head(species)
ears(species)
tail(species)
ears("Lop")
accessories()
export_and_preview()