feat(characters): add modular woodland traveler kit
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user