poselab-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- poselab_mcp/__init__.py +2 -0
- poselab_mcp/__main__.py +3 -0
- poselab_mcp/blender/lab.py +643 -0
- poselab_mcp/blender/sample.py +273 -0
- poselab_mcp/blender/worker.py +54 -0
- poselab_mcp/server.py +159 -0
- poselab_mcp/sheet.py +29 -0
- poselab_mcp/worker_client.py +110 -0
- poselab_mcp-0.1.0.dist-info/METADATA +142 -0
- poselab_mcp-0.1.0.dist-info/RECORD +13 -0
- poselab_mcp-0.1.0.dist-info/WHEEL +4 -0
- poselab_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- poselab_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
poselab_mcp/__init__.py
ADDED
poselab_mcp/__main__.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
# Pose Lab, inside Blender: one first-person rig (arms on their idle pose, a rifle on the gun bone, clips) loaded once,
|
|
2
|
+
# and measured answers about it. Every position goes in and comes out in a named frame, so no caller guesses axes:
|
|
3
|
+
# gun the gun bone as it stands now, Unreal-style axes, cm: +X the gun's left, +Y along the barrel, +Z up
|
|
4
|
+
# arms the arms' space, Unreal-style axes, cm (the eye sits at the rig's "eye")
|
|
5
|
+
# view the player's view from the eye, cm: +X right, +Y forward, +Z up
|
|
6
|
+
# Turns use the player's words: roll + turns the gun's right side up, swing + the muzzle left, pitch + the muzzle up.
|
|
7
|
+
# worker.py runs it; poselab_mcp.server exposes it as MCP tools.
|
|
8
|
+
import fnmatch
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
|
|
14
|
+
import bpy
|
|
15
|
+
from mathutils import Matrix, Quaternion, Vector
|
|
16
|
+
from mathutils.kdtree import KDTree
|
|
17
|
+
|
|
18
|
+
FINGERS = ("index", "middle", "ring", "pinky", "thumb")
|
|
19
|
+
RADIUS = {"forearm": 0.030, "palm": 0.018, "finger": 0.0075} # m: the capsules a body part is tested with
|
|
20
|
+
DEFAULT_POLES = {"l": [45.0, -20.0, 95.0], "r": [-45.0, -20.0, 95.0]} # arms frame, cm: where each elbow points
|
|
21
|
+
# bone names: the Unreal mannequin's by default; a rig's "bones" entry renames any of them
|
|
22
|
+
DEFAULT_BONES = {"gun": "ik_hand_gun", "upperarm": "upperarm_{s}", "lowerarm": "lowerarm_{s}", "hand": "hand_{s}",
|
|
23
|
+
"finger": "{f}_{j}_{s}"}
|
|
24
|
+
|
|
25
|
+
# the view: the eye looks along +Y (Blender -Y), up is +Z, the player's right is -X
|
|
26
|
+
VIEW_RIGHT, VIEW_FWD, VIEW_UP = Vector((-1.0, 0.0, 0.0)), Vector((0.0, -1.0, 0.0)), Vector((0.0, 0.0, 1.0))
|
|
27
|
+
H_FOV, ASPECT = 90.0, 16.0 / 9.0
|
|
28
|
+
PUBLIC = ("load_rig", "describe", "list_rigs", "pose_idle", "pose_clip", "move_part", "move_gun", "reach", "snapshot",
|
|
29
|
+
"where", "distance", "clearance", "faces_eye", "visible", "screen", "solve", "render")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ue(v):
|
|
33
|
+
"""Unreal-style cm (x, y, z) to Blender metres."""
|
|
34
|
+
return Vector((v[0], -v[1], v[2])) / 100.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ue_back(v):
|
|
38
|
+
return Vector((v[0], -v[1], v[2])) * 100.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def r2(v, n=2):
|
|
42
|
+
return [round(c, n) for c in v]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fit(src, dst):
|
|
46
|
+
"""The rotation, uniform scale and move that best carries the points src onto dst (Umeyama), as a matrix."""
|
|
47
|
+
import numpy as np
|
|
48
|
+
A, B = np.array([tuple(p) for p in src]), np.array([tuple(p) for p in dst])
|
|
49
|
+
ca, cb = A.mean(0), B.mean(0)
|
|
50
|
+
U, S, Vt = np.linalg.svd((A - ca).T @ (B - cb))
|
|
51
|
+
D = np.diag([1.0, 1.0, np.sign(np.linalg.det(Vt.T @ U.T))])
|
|
52
|
+
Rm = Vt.T @ D @ U.T
|
|
53
|
+
var = ((A - ca) ** 2).sum()
|
|
54
|
+
k = (S * np.diag(D)).sum() / var if var > 0 else 1.0
|
|
55
|
+
t = cb - k * Rm @ ca
|
|
56
|
+
return Matrix([[Rm[i, 0] * k, Rm[i, 1] * k, Rm[i, 2] * k, t[i]] for i in range(3)] + [[0.0, 0.0, 0.0, 1.0]])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Lab:
|
|
60
|
+
def __init__(self, rigs_file, out_dir):
|
|
61
|
+
self.rigs_file = rigs_file
|
|
62
|
+
self.out_dir = os.path.realpath(out_dir)
|
|
63
|
+
os.makedirs(self.out_dir, exist_ok=True)
|
|
64
|
+
self.rig = None
|
|
65
|
+
self.snapshots = {}
|
|
66
|
+
|
|
67
|
+
# --- where it may write ----------------------------------------------------------------------------------
|
|
68
|
+
def _own(self, path):
|
|
69
|
+
"""Pose Lab writes only inside its output folder: never the rigs, the models or anything else."""
|
|
70
|
+
full = os.path.realpath(path)
|
|
71
|
+
if os.path.commonpath([os.path.normcase(self.out_dir), os.path.normcase(full)]) != os.path.normcase(self.out_dir):
|
|
72
|
+
raise PermissionError("Pose Lab writes only under %s, not %s" % (self.out_dir, path))
|
|
73
|
+
return full
|
|
74
|
+
|
|
75
|
+
# --- rigs -------------------------------------------------------------------------------------------------
|
|
76
|
+
def _rigs(self):
|
|
77
|
+
rigs = {"sample": {"title": "the built-in sample: simple arms and a rifle made in Blender (free to share)", "builtin": True}}
|
|
78
|
+
if self.rigs_file and os.path.exists(self.rigs_file):
|
|
79
|
+
for k, v in json.load(open(self.rigs_file)).items():
|
|
80
|
+
if not k.startswith("_"):
|
|
81
|
+
rigs[k] = v
|
|
82
|
+
return rigs
|
|
83
|
+
|
|
84
|
+
def list_rigs(self):
|
|
85
|
+
"""The rigs: the built-in sample and those in the rigs file (POSELAB_RIGS)."""
|
|
86
|
+
return {"rigs_file": self.rigs_file or None, "rigs": {k: v.get("title", "") for k, v in self._rigs().items()}}
|
|
87
|
+
|
|
88
|
+
def load_rig(self, rig):
|
|
89
|
+
"""Loads a rig: the arms on their idle pose, the rifle on the gun bone, the rig's clips."""
|
|
90
|
+
rigs = self._rigs()
|
|
91
|
+
if rig not in rigs:
|
|
92
|
+
raise ValueError("no rig %r; rigs: %s" % (rig, list(rigs)))
|
|
93
|
+
R = dict(rigs[rig])
|
|
94
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
95
|
+
self.sc = bpy.context.scene
|
|
96
|
+
self.names = dict(DEFAULT_BONES, **R.get("bones", {}))
|
|
97
|
+
if R.get("builtin"):
|
|
98
|
+
from sample import build
|
|
99
|
+
made = build()
|
|
100
|
+
self.arm, self.meshes, self.rifle_arm, self.rifle_mesh = made["arm"], made["meshes"], made["rifle_arm"], made["rifle_mesh"]
|
|
101
|
+
R.update(made["config"])
|
|
102
|
+
else:
|
|
103
|
+
self._load_files(R)
|
|
104
|
+
for pb in self.arm.pose.bones:
|
|
105
|
+
pb.rotation_mode = 'QUATERNION'
|
|
106
|
+
self.idle = {pb.name: (pb.location.copy(), pb.rotation_quaternion.copy(), pb.scale.copy()) for pb in self.arm.pose.bones}
|
|
107
|
+
if self.arm.animation_data:
|
|
108
|
+
self.arm.animation_data.action = None
|
|
109
|
+
self.R, self.rig = R, rig
|
|
110
|
+
self.G0 = (self.arm.matrix_world @ self.arm.pose.bones[self.names["gun"]].matrix).copy()
|
|
111
|
+
self.rifle_rest = Matrix.Translation(self.G0.translation + ue(R.get("gun_offset", (0, 0, 0)))) @ self.rifle_arm.matrix_world
|
|
112
|
+
lower = {b.name.lower(): b.name for b in self.rifle_arm.pose.bones}
|
|
113
|
+
self.part_bones = {p: [lower[b.lower()] for b in bones if b.lower() in lower] for p, bones in R.get("parts", {}).items()}
|
|
114
|
+
self.travel = {}
|
|
115
|
+
self.poles = dict(DEFAULT_POLES, **R.get("poles", {}))
|
|
116
|
+
self.eye = ue(R.get("eye", (0.0, 0.0, 160.0)))
|
|
117
|
+
if not R.get("builtin"):
|
|
118
|
+
self._load_clips(R)
|
|
119
|
+
else:
|
|
120
|
+
self.clips, self.clip_fit = {}, {}
|
|
121
|
+
self.sc.render.fps, self.sc.render.fps_base = 30, 1.0
|
|
122
|
+
self.snapshots = {}
|
|
123
|
+
self.pose_idle()
|
|
124
|
+
return self.describe()
|
|
125
|
+
|
|
126
|
+
def _load_files(self, R):
|
|
127
|
+
folder = os.path.join(os.path.dirname(os.path.abspath(self.rigs_file)), R["folder"])
|
|
128
|
+
self.folder = folder
|
|
129
|
+
bpy.ops.import_scene.fbx(filepath=os.path.join(folder, R["arms"]))
|
|
130
|
+
old = [o for o in bpy.data.objects if o.type == 'ARMATURE'][0]
|
|
131
|
+
mesh = [o for o in bpy.data.objects if o.type == 'MESH'][0]
|
|
132
|
+
if R.get("idle"):
|
|
133
|
+
# the idle clip's armature drives the mesh (a mesh file's own armature may have a bad rest)
|
|
134
|
+
before = set(bpy.data.objects.keys())
|
|
135
|
+
bpy.ops.import_scene.fbx(filepath=os.path.join(folder, R["idle"]))
|
|
136
|
+
self.arm = [o for o in bpy.data.objects if o.type == 'ARMATURE' and o.name not in before][0]
|
|
137
|
+
for m in mesh.modifiers:
|
|
138
|
+
if m.type == 'ARMATURE':
|
|
139
|
+
m.object = self.arm
|
|
140
|
+
mw = mesh.matrix_world.copy()
|
|
141
|
+
mesh.parent = self.arm
|
|
142
|
+
mesh.matrix_world = mw
|
|
143
|
+
bpy.data.objects.remove(old)
|
|
144
|
+
act = self.arm.animation_data.action
|
|
145
|
+
self.sc.frame_set(int(act.frame_range[0]))
|
|
146
|
+
bpy.context.view_layer.update()
|
|
147
|
+
else:
|
|
148
|
+
self.arm = old
|
|
149
|
+
self.meshes = [mesh]
|
|
150
|
+
before = set(bpy.data.objects.keys())
|
|
151
|
+
bpy.ops.import_scene.fbx(filepath=os.path.join(folder, R["gun"]))
|
|
152
|
+
new = [o for o in bpy.data.objects if o.name not in before]
|
|
153
|
+
self.rifle_arm = [o for o in new if o.type == 'ARMATURE'][0]
|
|
154
|
+
self.rifle_mesh = [o for o in new if o.type == 'MESH'][0]
|
|
155
|
+
|
|
156
|
+
def _load_clips(self, R):
|
|
157
|
+
# bone data (<clip>.pose.json, the same idle armature) is taken as it is; an FBX clip sits on its own hidden
|
|
158
|
+
# armature and is carried over in world terms: ours = T @ clip @ C[bone], from the two rest poses
|
|
159
|
+
self.clips, self.clip_fit = {}, {}
|
|
160
|
+
rest = {b.name: self.arm.matrix_world @ b.matrix_local for b in self.arm.data.bones}
|
|
161
|
+
for name, f in R.get("clips", {}).items():
|
|
162
|
+
path = os.path.join(self.folder, f)
|
|
163
|
+
if f.endswith(".pose.json"):
|
|
164
|
+
data = json.load(open(path))
|
|
165
|
+
self.clips[name] = ("json", data["frames"], float(data["fps"]), None, None)
|
|
166
|
+
self.clip_fit[name] = {"source": "bone data"}
|
|
167
|
+
continue
|
|
168
|
+
before = set(bpy.data.objects.keys())
|
|
169
|
+
bpy.ops.import_scene.fbx(filepath=path)
|
|
170
|
+
fps = self.sc.render.fps / self.sc.render.fps_base
|
|
171
|
+
for o in [o for o in bpy.data.objects if o.name not in before]:
|
|
172
|
+
if o.type == 'ARMATURE' and o.animation_data and o.animation_data.action and name not in self.clips:
|
|
173
|
+
o.hide_render = True # still evaluated (a viewport-hidden armature does not animate)
|
|
174
|
+
crest = {b.name: o.matrix_world @ b.matrix_local for b in o.data.bones}
|
|
175
|
+
shared = [b.name for b in self.arm.data.bones if b.name in crest]
|
|
176
|
+
T = fit([crest[b].translation for b in shared], [rest[b].translation for b in shared])
|
|
177
|
+
C = {b: crest[b].inverted() @ T.inverted() @ rest[b] for b in shared}
|
|
178
|
+
worst = max(((T @ crest[b]).translation - rest[b].translation).length for b in shared) * 100.0
|
|
179
|
+
self.clips[name] = (o, o.animation_data.action, fps, T, C)
|
|
180
|
+
# a large mismatch means the clip's skeleton is not this one (or Blender misread an FBX it wrote)
|
|
181
|
+
self.clip_fit[name] = {"source": "fbx", "rest_mismatch_cm": round(worst, 3)}
|
|
182
|
+
else:
|
|
183
|
+
bpy.data.objects.remove(o)
|
|
184
|
+
|
|
185
|
+
def describe(self):
|
|
186
|
+
"""What is loaded and the conventions every tool uses."""
|
|
187
|
+
self._need()
|
|
188
|
+
return {
|
|
189
|
+
"rig": self.rig, "title": self.R.get("title"),
|
|
190
|
+
"frames": {"gun": "the gun bone as it stands now, Unreal-style axes, cm: +X gun's left, +Y along the barrel, +Z up",
|
|
191
|
+
"arms": "the arms' space, Unreal-style axes, cm",
|
|
192
|
+
"view": "from the eye, cm: +X right, +Y forward, +Z up"},
|
|
193
|
+
"turns": "roll + the gun's right side up; swing + the muzzle left; pitch + the muzzle up (degrees)",
|
|
194
|
+
"points": self.R.get("points", {}), "normals": self.R.get("normals", {}),
|
|
195
|
+
"clips": {k: {"seconds": round(self._clip_len(k), 3), **self.clip_fit.get(k, {})} for k in self.clips},
|
|
196
|
+
"parts": self.part_bones, "eye_arms_cm": list(self.R.get("eye", (0.0, 0.0, 160.0))),
|
|
197
|
+
"body_names": "bones by name, fingertips as <finger>_tip_<l|r>, rig points by name",
|
|
198
|
+
"writes_only_to": self.out_dir,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def _need(self):
|
|
202
|
+
if not self.rig:
|
|
203
|
+
raise RuntimeError("no rig loaded: call load_rig first (list_rigs shows them)")
|
|
204
|
+
|
|
205
|
+
# --- names and frames -------------------------------------------------------------------------------------
|
|
206
|
+
def _n(self, part, side=None, f=None, j=None):
|
|
207
|
+
return self.names[part].format(s=side, f=f, j=j)
|
|
208
|
+
|
|
209
|
+
def _bw(self, n):
|
|
210
|
+
return self.arm.matrix_world @ self.arm.pose.bones[n].matrix
|
|
211
|
+
|
|
212
|
+
def _gun(self):
|
|
213
|
+
return self._bw(self.names["gun"]) @ self.G0.inverted() @ Matrix.Translation(self.G0.translation)
|
|
214
|
+
|
|
215
|
+
def _to(self, p, frame):
|
|
216
|
+
if frame == "gun":
|
|
217
|
+
return ue_back(self._gun().inverted() @ p)
|
|
218
|
+
if frame == "arms":
|
|
219
|
+
return ue_back(p)
|
|
220
|
+
if frame == "view":
|
|
221
|
+
d = (p - self.eye) * 100.0
|
|
222
|
+
return Vector((d.dot(VIEW_RIGHT), d.dot(VIEW_FWD), d.dot(VIEW_UP)))
|
|
223
|
+
raise ValueError("frame must be gun, arms or view")
|
|
224
|
+
|
|
225
|
+
def _from(self, v, frame):
|
|
226
|
+
v = Vector(v)
|
|
227
|
+
if frame == "gun":
|
|
228
|
+
return self._gun() @ ue(v)
|
|
229
|
+
if frame == "arms":
|
|
230
|
+
return ue(v)
|
|
231
|
+
if frame == "view":
|
|
232
|
+
return self.eye + (VIEW_RIGHT * v.x + VIEW_FWD * v.y + VIEW_UP * v.z) / 100.0
|
|
233
|
+
raise ValueError("frame must be gun, arms or view")
|
|
234
|
+
|
|
235
|
+
def _tip(self, f, side):
|
|
236
|
+
return self.arm.matrix_world @ self.arm.pose.bones[self._n("finger", side, f, "03")].tail
|
|
237
|
+
|
|
238
|
+
def _point(self, p, frame="gun"):
|
|
239
|
+
if isinstance(p, str):
|
|
240
|
+
pts = self.R.get("points", {})
|
|
241
|
+
if p in pts:
|
|
242
|
+
return self._gun() @ ue(pts[p])
|
|
243
|
+
if "_tip_" in p:
|
|
244
|
+
f, side = p.split("_tip_")
|
|
245
|
+
return self._tip(f, side)
|
|
246
|
+
if p in self.arm.pose.bones:
|
|
247
|
+
return self._bw(p).translation
|
|
248
|
+
raise ValueError("unknown point %r" % p)
|
|
249
|
+
return self._from(p, frame)
|
|
250
|
+
|
|
251
|
+
# --- posing -----------------------------------------------------------------------------------------------
|
|
252
|
+
def _set_world(self, n, M):
|
|
253
|
+
self.arm.pose.bones[n].matrix = self.arm.matrix_world.inverted() @ M
|
|
254
|
+
bpy.context.view_layer.update()
|
|
255
|
+
|
|
256
|
+
def _turn(self, n, q):
|
|
257
|
+
M = self._bw(n)
|
|
258
|
+
R = Matrix.Translation(M.translation) @ q.to_matrix().to_4x4() @ Matrix.Translation(-M.translation) @ M
|
|
259
|
+
self._set_world(n, Matrix.LocRotScale(R.translation, R.to_quaternion(), M.to_scale()))
|
|
260
|
+
|
|
261
|
+
def _two_bone(self, side, goal, pole=None):
|
|
262
|
+
upper, lower, hand = self._n("upperarm", side), self._n("lowerarm", side), self._n("hand", side)
|
|
263
|
+
pole = ue(pole or self.poles[side])
|
|
264
|
+
S, E, W = self._bw(upper).translation, self._bw(lower).translation, self._bw(hand).translation
|
|
265
|
+
l1, l2 = (E - S).length, (W - E).length
|
|
266
|
+
d = min((goal - S).length, l1 + l2 - 1e-4)
|
|
267
|
+
axis = (goal - S).normalized()
|
|
268
|
+
sidev = (pole - S) - axis * (pole - S).dot(axis)
|
|
269
|
+
sidev = sidev.normalized() if sidev.length > 1e-6 else Vector((0, 0, -1))
|
|
270
|
+
x = (l1 * l1 - l2 * l2 + d * d) / (2 * d)
|
|
271
|
+
h = max(l1 * l1 - x * x, 0.0) ** 0.5
|
|
272
|
+
E2, W2 = S + axis * x + sidev * h, S + axis * d
|
|
273
|
+
self._turn(upper, (E - S).rotation_difference(E2 - S))
|
|
274
|
+
E, W = self._bw(lower).translation, self._bw(hand).translation
|
|
275
|
+
self._turn(lower, (W - E).rotation_difference(W2 - E))
|
|
276
|
+
return (self._bw(hand).translation - goal).length * 100.0
|
|
277
|
+
|
|
278
|
+
def _place_rifle(self):
|
|
279
|
+
self.rifle_arm.matrix_world = self._bw(self.names["gun"]) @ self.G0.inverted() @ self.rifle_rest
|
|
280
|
+
for bones in self.part_bones.values():
|
|
281
|
+
for b in bones:
|
|
282
|
+
self.rifle_arm.pose.bones[b].matrix_basis = Matrix.Identity(4)
|
|
283
|
+
bpy.context.view_layer.update()
|
|
284
|
+
q = self._gun().to_quaternion()
|
|
285
|
+
moved = {}
|
|
286
|
+
for part, cm in self.travel.items():
|
|
287
|
+
for b in self.part_bones.get(part, []):
|
|
288
|
+
moved[b] = max(moved.get(b, 0.0), cm)
|
|
289
|
+
for b, cm in moved.items():
|
|
290
|
+
pb = self.rifle_arm.pose.bones[b]
|
|
291
|
+
M = self.rifle_arm.matrix_world @ pb.matrix
|
|
292
|
+
pb.matrix = self.rifle_arm.matrix_world.inverted() @ (Matrix.Translation(q @ ue((0.0, -cm, 0.0))) @ M)
|
|
293
|
+
bpy.context.view_layer.update()
|
|
294
|
+
|
|
295
|
+
def pose_idle(self):
|
|
296
|
+
"""The arms on the idle pose, the rifle on the gun bone, every part home."""
|
|
297
|
+
self._need()
|
|
298
|
+
for pb in self.arm.pose.bones:
|
|
299
|
+
l, r, s = self.idle[pb.name]
|
|
300
|
+
pb.location, pb.rotation_quaternion, pb.scale = l, r, s
|
|
301
|
+
self.travel = {}
|
|
302
|
+
bpy.context.view_layer.update()
|
|
303
|
+
self._place_rifle()
|
|
304
|
+
return {"pose": "idle"}
|
|
305
|
+
|
|
306
|
+
def _clip_len(self, clip):
|
|
307
|
+
o, act, fps, T, C = self.clips[clip]
|
|
308
|
+
return (len(act) - 1) / fps if o == "json" else (act.frame_range[1] - act.frame_range[0]) / fps
|
|
309
|
+
|
|
310
|
+
def pose_clip(self, clip, seconds):
|
|
311
|
+
"""The arms as a clip has them at a time; the rifle follows the gun bone, its parts stay where they are."""
|
|
312
|
+
self._need()
|
|
313
|
+
if clip not in self.clips:
|
|
314
|
+
raise ValueError("no clip %r; clips: %s" % (clip, list(self.clips)))
|
|
315
|
+
o, act, fps, T, C = self.clips[clip]
|
|
316
|
+
if o == "json":
|
|
317
|
+
x = min(max(seconds * fps, 0.0), len(act) - 1)
|
|
318
|
+
i = int(math.floor(x))
|
|
319
|
+
j, k = min(i + 1, len(act) - 1), x - i
|
|
320
|
+
for pb in self.arm.pose.bones:
|
|
321
|
+
if pb.name in act[i]:
|
|
322
|
+
(l0, q0), (l1, q1) = act[i][pb.name], act[j][pb.name]
|
|
323
|
+
pb.location = Vector(l0).lerp(Vector(l1), k)
|
|
324
|
+
pb.rotation_quaternion = Quaternion(q0).slerp(Quaternion(q1), k)
|
|
325
|
+
bpy.context.view_layer.update()
|
|
326
|
+
else:
|
|
327
|
+
f = act.frame_range[0] + seconds * fps
|
|
328
|
+
self.sc.frame_set(int(math.floor(f)), subframe=f % 1.0)
|
|
329
|
+
want = {b: T @ (o.matrix_world @ o.pose.bones[b].matrix) @ C[b] for b in C}
|
|
330
|
+
inv = self.arm.matrix_world.inverted()
|
|
331
|
+
for pb in self.arm.pose.bones: # parents before children
|
|
332
|
+
if pb.name in want:
|
|
333
|
+
pb.matrix = inv @ want[pb.name]
|
|
334
|
+
bpy.context.view_layer.update()
|
|
335
|
+
self._place_rifle()
|
|
336
|
+
return {"clip": clip, "seconds": seconds, "length": round(self._clip_len(clip), 3)}
|
|
337
|
+
|
|
338
|
+
def move_part(self, part, cm):
|
|
339
|
+
"""A moving part of the rifle drawn back along the barrel (cm from home)."""
|
|
340
|
+
self._need()
|
|
341
|
+
if part not in self.part_bones:
|
|
342
|
+
raise ValueError("no part %r; parts: %s" % (part, list(self.part_bones)))
|
|
343
|
+
self.travel[part] = float(cm)
|
|
344
|
+
self._place_rifle()
|
|
345
|
+
return {"part": part, "back_cm": cm}
|
|
346
|
+
|
|
347
|
+
def move_gun(self, roll=0.0, swing=0.0, pitch=0.0, right=0.0, forward=0.0, up=0.0, pivot="stock", keep_hands=("l", "r")):
|
|
348
|
+
"""Moves the rifle from where it stands: roll about the bore, swing and pitch about the pivot, then a move in
|
|
349
|
+
the view (cm). The hands in keep_hands keep their hold (arm IK)."""
|
|
350
|
+
self._need()
|
|
351
|
+
G = self._gun()
|
|
352
|
+
held = {s: G.inverted() @ self._bw(self._n("hand", s)) for s in keep_hands}
|
|
353
|
+
barrel = (G.to_quaternion() @ Vector((0.0, -1.0, 0.0))).normalized()
|
|
354
|
+
bore = self._point("bore") if "bore" in self.R.get("points", {}) else G.translation
|
|
355
|
+
pv = self._point(pivot) if isinstance(pivot, str) else self._from(pivot, "gun")
|
|
356
|
+
lateral = barrel.cross(VIEW_UP).normalized()
|
|
357
|
+
|
|
358
|
+
def about(point, axis, deg):
|
|
359
|
+
return Matrix.Translation(point) @ Quaternion(axis, math.radians(deg)).to_matrix().to_4x4() @ Matrix.Translation(-point)
|
|
360
|
+
|
|
361
|
+
M = Matrix.Translation((VIEW_RIGHT * right + VIEW_FWD * forward + VIEW_UP * up) / 100.0) \
|
|
362
|
+
@ about(pv, VIEW_UP, swing) @ about(pv, lateral, pitch) @ about(bore, -barrel, roll)
|
|
363
|
+
self._set_world(self.names["gun"], M @ self._bw(self.names["gun"]))
|
|
364
|
+
self._place_rifle()
|
|
365
|
+
miss = {}
|
|
366
|
+
G = self._gun()
|
|
367
|
+
for s, H in held.items():
|
|
368
|
+
W = G @ H
|
|
369
|
+
miss[s] = round(self._two_bone(s, W.translation), 2)
|
|
370
|
+
hn = self._n("hand", s)
|
|
371
|
+
self._set_world(hn, Matrix.LocRotScale(self._bw(hn).translation, W.to_quaternion(), W.to_scale()))
|
|
372
|
+
return {"hands_off_their_hold_cm": miss, "barrel_off_deg": round(self._barrel_off(), 2)}
|
|
373
|
+
|
|
374
|
+
def reach(self, side, target, frame="gun", pole=None):
|
|
375
|
+
"""A wrist to a point by arm IK; the hand keeps its turn. pole: where the elbow points (arms frame, cm)."""
|
|
376
|
+
self._need()
|
|
377
|
+
hn = self._n("hand", side)
|
|
378
|
+
H = self._bw(hn)
|
|
379
|
+
miss = self._two_bone(side, self._point(target, frame), pole)
|
|
380
|
+
self._set_world(hn, Matrix.LocRotScale(self._bw(hn).translation, H.to_quaternion(), H.to_scale()))
|
|
381
|
+
return {"wrist_off_target_cm": round(miss, 2)}
|
|
382
|
+
|
|
383
|
+
def snapshot(self, action, name):
|
|
384
|
+
"""save or load a named pose (the arms' bones and the rifle's parts)."""
|
|
385
|
+
self._need()
|
|
386
|
+
if action == "save":
|
|
387
|
+
self.snapshots[name] = ({pb.name: (pb.location.copy(), pb.rotation_quaternion.copy()) for pb in self.arm.pose.bones}, dict(self.travel))
|
|
388
|
+
return {"saved": name}
|
|
389
|
+
if name not in self.snapshots:
|
|
390
|
+
raise ValueError("no snapshot %r; saved: %s" % (name, list(self.snapshots)))
|
|
391
|
+
bones, travel = self.snapshots[name]
|
|
392
|
+
for pb in self.arm.pose.bones:
|
|
393
|
+
pb.location, pb.rotation_quaternion = bones[pb.name]
|
|
394
|
+
self.travel = dict(travel)
|
|
395
|
+
bpy.context.view_layer.update()
|
|
396
|
+
self._place_rifle()
|
|
397
|
+
return {"loaded": name}
|
|
398
|
+
|
|
399
|
+
# --- measuring --------------------------------------------------------------------------------------------
|
|
400
|
+
def where(self, names, frame="gun"):
|
|
401
|
+
"""Where bones, fingertips and rig points are, in the frame."""
|
|
402
|
+
self._need()
|
|
403
|
+
return {n: r2(self._to(self._point(n), frame), 2) for n in names}
|
|
404
|
+
|
|
405
|
+
def distance(self, a, b, frame="gun"):
|
|
406
|
+
"""The distance between two points, cm, and b minus a in the frame."""
|
|
407
|
+
self._need()
|
|
408
|
+
pa, pb = self._point(a, frame), self._point(b, frame)
|
|
409
|
+
return {"cm": round((pb - pa).length * 100.0, 2), "b_minus_a": r2(self._to(pb, frame) - self._to(pa, frame), 2)}
|
|
410
|
+
|
|
411
|
+
def _segments(self, parts):
|
|
412
|
+
out = []
|
|
413
|
+
for side in ("l", "r"):
|
|
414
|
+
if parts not in ("both", side, {"l": "left", "r": "right"}[side]):
|
|
415
|
+
continue
|
|
416
|
+
b = lambda part: self._bw(self._n(part, side)).translation
|
|
417
|
+
f = lambda fi, j: self._bw(self._n("finger", side, fi, j)).translation
|
|
418
|
+
out.append(("forearm_" + side, b("lowerarm"), b("hand"), RADIUS["forearm"]))
|
|
419
|
+
out.append(("palm_" + side, b("hand"), f("middle", "01"), RADIUS["palm"]))
|
|
420
|
+
for fi in FINGERS:
|
|
421
|
+
pts = [f(fi, "01"), f(fi, "02"), f(fi, "03"), self._tip(fi, side)]
|
|
422
|
+
for i in range(3):
|
|
423
|
+
out.append(("%s%d_%s" % (fi, i + 1, side), pts[i], pts[i + 1], RADIUS["finger"]))
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
def _tree(self):
|
|
427
|
+
dg = bpy.context.evaluated_depsgraph_get()
|
|
428
|
+
ev = self.rifle_mesh.evaluated_get(dg)
|
|
429
|
+
me = ev.to_mesh()
|
|
430
|
+
pts = [self.rifle_mesh.matrix_world @ v.co for v in me.vertices]
|
|
431
|
+
ev.to_mesh_clear()
|
|
432
|
+
tree = KDTree(len(pts))
|
|
433
|
+
for i, p in enumerate(pts):
|
|
434
|
+
tree.insert(p, i)
|
|
435
|
+
tree.balance()
|
|
436
|
+
return tree
|
|
437
|
+
|
|
438
|
+
def clearance(self, parts="both", ignore=(), frame="gun", top=5):
|
|
439
|
+
"""How deep the rifle sits inside the arms (capsules: forearm 3 cm, palm 1.8 cm, each finger segment 0.75 cm).
|
|
440
|
+
worst_cm 0: nothing touches. ignore: segment names, prefixes or wildcards (thumb, index3_r, *_l)."""
|
|
441
|
+
self._need()
|
|
442
|
+
tree = self._tree()
|
|
443
|
+
hits = {}
|
|
444
|
+
for name, a, b, r in self._segments(parts):
|
|
445
|
+
if any(name.startswith(i) or fnmatch.fnmatch(name, i) for i in ignore):
|
|
446
|
+
continue
|
|
447
|
+
n = max(2, int((b - a).length / 0.004))
|
|
448
|
+
for i in range(n + 1):
|
|
449
|
+
p = a.lerp(b, i / n)
|
|
450
|
+
co, idx, d = tree.find(p)
|
|
451
|
+
if co is not None and d < r and (r - d) * 100.0 > hits.get(name, (0.0,))[0]:
|
|
452
|
+
hits[name] = ((r - d) * 100.0, co.copy())
|
|
453
|
+
ranked = sorted(hits.items(), key=lambda kv: -kv[1][0])
|
|
454
|
+
return {"worst_cm": round(ranked[0][1][0], 2) if ranked else 0.0,
|
|
455
|
+
"contacts": [{"segment": k, "depth_cm": round(v[0], 2), "rifle_point": r2(self._to(v[1], frame), 1)} for k, v in ranked[:top]]}
|
|
456
|
+
|
|
457
|
+
def faces_eye(self, point="port", normal=None):
|
|
458
|
+
"""How squarely a surface faces the eye: 1 square on, 0 edge on, below 0 turned away."""
|
|
459
|
+
self._need()
|
|
460
|
+
p = self._point(point)
|
|
461
|
+
n = normal if normal is not None else self.R.get("normals", {}).get(point)
|
|
462
|
+
if n is None:
|
|
463
|
+
raise ValueError("no normal for %r: give one in the gun frame" % (point,))
|
|
464
|
+
nw = (self._gun().to_quaternion() @ ue(n)).normalized()
|
|
465
|
+
to_eye = self.eye - p
|
|
466
|
+
dot = nw.dot(to_eye.normalized())
|
|
467
|
+
return {"facing": round(dot, 3), "angle_from_square_deg": round(math.degrees(math.acos(max(-1.0, min(1.0, dot)))), 1),
|
|
468
|
+
"distance_cm": round(to_eye.length * 100.0, 1)}
|
|
469
|
+
|
|
470
|
+
def visible(self, point="port", radius_cm=0.6, rings=2):
|
|
471
|
+
"""The share of a small disc round a point the eye sees with nothing (arms, rifle) in the way."""
|
|
472
|
+
self._need()
|
|
473
|
+
target = self._point(point)
|
|
474
|
+
d = (target - self.eye).normalized()
|
|
475
|
+
u = d.cross(VIEW_UP)
|
|
476
|
+
u = u.normalized() if u.length > 1e-6 else Vector((1, 0, 0))
|
|
477
|
+
v = d.cross(u).normalized()
|
|
478
|
+
samples = [target]
|
|
479
|
+
for ring in range(1, rings + 1):
|
|
480
|
+
rr = radius_cm / 100.0 * ring / rings
|
|
481
|
+
for k in range(8):
|
|
482
|
+
a = 2 * math.pi * k / 8
|
|
483
|
+
samples.append(target + (u * math.cos(a) + v * math.sin(a)) * rr)
|
|
484
|
+
dg = bpy.context.evaluated_depsgraph_get()
|
|
485
|
+
seen = 0
|
|
486
|
+
for s in samples:
|
|
487
|
+
ray = s - self.eye
|
|
488
|
+
hit, loc, nrm, idx, obj, mat = self.sc.ray_cast(dg, self.eye, ray.normalized(), distance=ray.length + 0.01)
|
|
489
|
+
if not hit or (loc - s).length < 0.006 or (loc - self.eye).length >= ray.length - 0.006:
|
|
490
|
+
seen += 1
|
|
491
|
+
return {"visible": round(seen / len(samples), 3), "samples": len(samples)}
|
|
492
|
+
|
|
493
|
+
def screen(self, point):
|
|
494
|
+
"""Where a point falls on a 90 degree, 16:9 screen from the eye: x, y from -1 to 1; on_screen if inside."""
|
|
495
|
+
self._need()
|
|
496
|
+
p = self._to(self._point(point), "view")
|
|
497
|
+
if p.y <= 0.0:
|
|
498
|
+
return {"on_screen": False, "behind_eye": True}
|
|
499
|
+
t = math.tan(math.radians(H_FOV / 2))
|
|
500
|
+
x, y = p.x / p.y / t, p.z / p.y / (t / ASPECT)
|
|
501
|
+
return {"on_screen": abs(x) <= 1.0 and abs(y) <= 1.0, "x": round(x, 3), "y": round(y, 3)}
|
|
502
|
+
|
|
503
|
+
def _barrel_off(self):
|
|
504
|
+
barrel = self._gun().to_quaternion() @ Vector((0.0, -1.0, 0.0))
|
|
505
|
+
return math.degrees(barrel.angle(VIEW_FWD))
|
|
506
|
+
|
|
507
|
+
# --- solving ----------------------------------------------------------------------------------------------
|
|
508
|
+
def _goal(self, g):
|
|
509
|
+
"""(met, value, shortfall >= 0) for one goal."""
|
|
510
|
+
t = g["type"]
|
|
511
|
+
if t == "faces_eye":
|
|
512
|
+
v = self.faces_eye(g.get("point", "port"), g.get("normal"))["facing"]
|
|
513
|
+
return v >= g.get("min", 0.5), v, max(0.0, g.get("min", 0.5) - v)
|
|
514
|
+
if t == "visible":
|
|
515
|
+
v = self.visible(g.get("point", "port"))["visible"]
|
|
516
|
+
return v >= g.get("min", 0.5), v, max(0.0, g.get("min", 0.5) - v)
|
|
517
|
+
if t == "on_screen":
|
|
518
|
+
v = 1.0 if self.screen(g["point"]).get("on_screen") else 0.0
|
|
519
|
+
return v > 0.5, v, 1.0 - v
|
|
520
|
+
if t == "clearance":
|
|
521
|
+
v = self.clearance(g.get("parts", "both"), g.get("ignore", ()))["worst_cm"]
|
|
522
|
+
return v <= g.get("max_cm", 0.0), v, max(0.0, v - g.get("max_cm", 0.0)) / 2.0
|
|
523
|
+
if t == "barrel":
|
|
524
|
+
v = self._barrel_off()
|
|
525
|
+
return v <= g.get("max_deg", 2.0), round(v, 2), max(0.0, v - g.get("max_deg", 2.0)) / 30.0
|
|
526
|
+
if t == "distance":
|
|
527
|
+
v = self.distance(g["a"], g["b"])["cm"]
|
|
528
|
+
return v <= g.get("max_cm", 0.5), v, max(0.0, v - g.get("max_cm", 0.5)) / 5.0
|
|
529
|
+
raise ValueError("goal type %r: faces_eye, visible, on_screen, clearance, barrel, distance" % t)
|
|
530
|
+
|
|
531
|
+
def solve(self, dofs, goals, keep_hands=("l", "r"), pivot="stock", samples=120, maximize=None, seed=1):
|
|
532
|
+
"""Searches rifle moves (dofs: roll, swing, pitch, right, forward, up -> [min, max]) from the current pose for
|
|
533
|
+
one that meets every goal; reports each goal at the best and how often any sample met it (never met: a fact
|
|
534
|
+
of the geometry, not a tuning miss). Leaves the scene at the best."""
|
|
535
|
+
self._need()
|
|
536
|
+
names = [k for k in dofs if k in ("roll", "swing", "pitch", "right", "forward", "up")]
|
|
537
|
+
if not names:
|
|
538
|
+
raise ValueError("dofs: name -> [min, max] for roll, swing, pitch, right, forward, up")
|
|
539
|
+
self.snapshot("save", "_solve_base")
|
|
540
|
+
rng = random.Random(seed)
|
|
541
|
+
met_count = [0] * len(goals)
|
|
542
|
+
|
|
543
|
+
def evaluate(x):
|
|
544
|
+
self.snapshot("load", "_solve_base")
|
|
545
|
+
self.move_gun(pivot=pivot, keep_hands=tuple(keep_hands), **dict(zip(names, x)))
|
|
546
|
+
res = [self._goal(g) for g in goals]
|
|
547
|
+
short = sum(r[2] for r in res)
|
|
548
|
+
bonus = res[maximize][1] if maximize is not None and short == 0 else 0.0
|
|
549
|
+
return short * 10.0 - bonus, res
|
|
550
|
+
|
|
551
|
+
lo = [float(dofs[n][0]) for n in names]
|
|
552
|
+
hi = [float(dofs[n][1]) for n in names]
|
|
553
|
+
tried, pool = 0, []
|
|
554
|
+
for i in range(samples):
|
|
555
|
+
x = [0.0 if (i == 0 and l <= 0.0 <= h) else rng.uniform(l, h) for l, h in zip(lo, hi)]
|
|
556
|
+
score, res = evaluate(x)
|
|
557
|
+
tried += 1
|
|
558
|
+
for k, r in enumerate(res):
|
|
559
|
+
met_count[k] += 1 if r[0] else 0
|
|
560
|
+
pool.append((score, x, res))
|
|
561
|
+
# a pattern search from each of the six best samples (goals met in different corners rarely meet in one)
|
|
562
|
+
best = None
|
|
563
|
+
for start in sorted(pool, key=lambda p: p[0])[:6]:
|
|
564
|
+
cur = start
|
|
565
|
+
step = [(h - l) / 8.0 for l, h in zip(lo, hi)]
|
|
566
|
+
for _ in range(4):
|
|
567
|
+
improved = True
|
|
568
|
+
while improved:
|
|
569
|
+
improved = False
|
|
570
|
+
for k in range(len(names)):
|
|
571
|
+
for sgn in (1, -1):
|
|
572
|
+
x = list(cur[1])
|
|
573
|
+
x[k] = min(hi[k], max(lo[k], x[k] + sgn * step[k]))
|
|
574
|
+
score, res = evaluate(x)
|
|
575
|
+
tried += 1
|
|
576
|
+
if score < cur[0] - 1e-6:
|
|
577
|
+
cur, improved = (score, x, res), True
|
|
578
|
+
step = [s_ / 2.0 for s_ in step]
|
|
579
|
+
if best is None or cur[0] < best[0]:
|
|
580
|
+
best = cur
|
|
581
|
+
self.snapshot("load", "_solve_base")
|
|
582
|
+
self.move_gun(pivot=pivot, keep_hands=tuple(keep_hands), **dict(zip(names, best[1])))
|
|
583
|
+
return {"all_met": all(r[0] for r in best[2]), "best": {n: round(v, 2) for n, v in zip(names, best[1])},
|
|
584
|
+
"goals": [{"goal": g, "met": bool(r[0]), "value": r[1], "met_in_samples": "%d of %d" % (m, samples)}
|
|
585
|
+
for g, r, m in zip(goals, best[2], met_count)],
|
|
586
|
+
"evaluations": tried, "never_met": [g for g, m in zip(goals, met_count) if m == 0]}
|
|
587
|
+
|
|
588
|
+
# --- pictures ---------------------------------------------------------------------------------------------
|
|
589
|
+
def render(self, views=("eye", "right", "left", "top"), size=(640, 360)):
|
|
590
|
+
"""Renders the pose: eye is the player's camera (90 degrees, 5 cm near plane); right, left, top and front
|
|
591
|
+
look at the rifle from outside. Images go to the output folder."""
|
|
592
|
+
self._need()
|
|
593
|
+
out_dir = self._own(os.path.join(self.out_dir, "renders"))
|
|
594
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
595
|
+
cam_data = bpy.data.cameras.get("poselab") or bpy.data.cameras.new("poselab")
|
|
596
|
+
cam = bpy.data.objects.get("poselab") or bpy.data.objects.new("poselab", cam_data)
|
|
597
|
+
if cam.name not in self.sc.collection.objects:
|
|
598
|
+
self.sc.collection.objects.link(cam)
|
|
599
|
+
cam_data.lens_unit = 'FOV'
|
|
600
|
+
self.sc.camera = cam
|
|
601
|
+
self.sc.render.engine = 'BLENDER_WORKBENCH'
|
|
602
|
+
sh = self.sc.display.shading
|
|
603
|
+
sh.light = 'STUDIO'
|
|
604
|
+
sh.show_cavity, sh.cavity_type = True, 'BOTH' # edges and creases read at a glance
|
|
605
|
+
sh.show_shadows, sh.shadow_intensity = True, 0.35
|
|
606
|
+
sh.show_specular_highlight = True
|
|
607
|
+
self.sc.display.render_aa = '16'
|
|
608
|
+
if self.R.get("styled"):
|
|
609
|
+
sh.color_type = 'MATERIAL' # the sample's own colours
|
|
610
|
+
else:
|
|
611
|
+
sh.color_type = 'OBJECT' # a plain arms-and-rifle look for any rig
|
|
612
|
+
for m in self.meshes:
|
|
613
|
+
m.color = (0.55, 0.6, 0.7, 1)
|
|
614
|
+
self.rifle_mesh.color = (0.12, 0.12, 0.13, 1)
|
|
615
|
+
self.sc.render.resolution_x, self.sc.render.resolution_y = size
|
|
616
|
+
G = self._gun()
|
|
617
|
+
centre = self._point("bore") if "bore" in self.R.get("points", {}) else G.translation
|
|
618
|
+
base = Vector(self.R.get("points", {}).get("bore", (0, 0, 0)))
|
|
619
|
+
spots = {"right": (-70.0, 10.0, 25.0), "left": (70.0, 10.0, 25.0), "top": (-8.0, 10.0, 75.0), "front": (-25.0, 90.0, 20.0)}
|
|
620
|
+
paths = []
|
|
621
|
+
for v in views:
|
|
622
|
+
if v == "eye":
|
|
623
|
+
cam_data.angle = math.radians(H_FOV)
|
|
624
|
+
cam_data.clip_start = 0.05
|
|
625
|
+
cam.location = self.eye
|
|
626
|
+
cam.rotation_euler = VIEW_FWD.to_track_quat('-Z', 'Y').to_euler()
|
|
627
|
+
elif v in spots:
|
|
628
|
+
cam_data.angle = math.radians(40.0)
|
|
629
|
+
cam_data.clip_start = 0.01
|
|
630
|
+
cam.location = G @ ue(base + Vector(spots[v]))
|
|
631
|
+
zc = (cam.location - centre).normalized()
|
|
632
|
+
upv = G.to_quaternion() @ Vector((0.0, 0.0, 1.0))
|
|
633
|
+
if abs(upv.dot(zc)) > 0.95:
|
|
634
|
+
upv = G.to_quaternion() @ Vector((0.0, -1.0, 0.0))
|
|
635
|
+
xc = upv.cross(zc).normalized()
|
|
636
|
+
cam.rotation_euler = Matrix((xc, zc.cross(xc), zc)).transposed().to_euler()
|
|
637
|
+
else:
|
|
638
|
+
raise ValueError("views: eye, right, left, top, front")
|
|
639
|
+
path = self._own(os.path.join(out_dir, "%s_%s.png" % (self.rig, v)))
|
|
640
|
+
self.sc.render.filepath = path
|
|
641
|
+
bpy.ops.render.render(write_still=True)
|
|
642
|
+
paths.append({"view": v, "path": path})
|
|
643
|
+
return {"images": paths}
|