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.
@@ -0,0 +1,2 @@
1
+ """Pose Lab: measured spatial answers for posing first-person arms and a rifle in Blender, over MCP."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -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}