weaponlab-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.
- weaponlab/__init__.py +3 -0
- weaponlab/__main__.py +79 -0
- weaponlab/blender/render_parts.py +175 -0
- weaponlab/blender/roll.py +98 -0
- weaponlab/blender/track.py +74 -0
- weaponlab/config.py +102 -0
- weaponlab/contract.py +221 -0
- weaponlab/gate.py +336 -0
- weaponlab/motion.py +72 -0
- weaponlab/probe.py +179 -0
- weaponlab/rig.py +291 -0
- weaponlab/server.py +233 -0
- weaponlab/spec.py +140 -0
- weaponlab_mcp-0.1.0.dist-info/METADATA +98 -0
- weaponlab_mcp-0.1.0.dist-info/RECORD +18 -0
- weaponlab_mcp-0.1.0.dist-info/WHEEL +4 -0
- weaponlab_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- weaponlab_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
weaponlab/__init__.py
ADDED
weaponlab/__main__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""weaponlab <command>
|
|
2
|
+
serve the MCP server (also: weaponlab-mcp)
|
|
3
|
+
contract job|rows|parts <file> [job] check a probe's file against the contract
|
|
4
|
+
init [folder] a new project: weaponlab.json and empty data
|
|
5
|
+
gate run <providers> [weapons] measure and store a run
|
|
6
|
+
gate verdict <run> [targets] the verdict on a run
|
|
7
|
+
gate baseline <run> a run becomes the baseline
|
|
8
|
+
rig fist <dump.obj> a melee hilt against the fist
|
|
9
|
+
motion profile <clip.fbx> [side] a clip's forearm roll"""
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _csv(s):
|
|
16
|
+
return [x for x in (s or "").split(",") if x]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def init(folder="."):
|
|
20
|
+
root = Path(folder).resolve()
|
|
21
|
+
data = root / "weaponlab" / "data"
|
|
22
|
+
(root / "weaponlab" / "specs").mkdir(parents=True, exist_ok=True)
|
|
23
|
+
data.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
cfg = root / "weaponlab.json"
|
|
25
|
+
if not cfg.exists():
|
|
26
|
+
cfg.write_text(json.dumps({"probe": {"command": [], "timeout_s": 1800, "busy": []}}, indent=1) + "\n", encoding="utf-8")
|
|
27
|
+
for name, empty in (("weapons.json", []), ("cases.json", []), ("metrics.json", {}), ("budgets.json", {}),
|
|
28
|
+
("actions.json", {"*": {"idle": {"seconds": 1.6}}})):
|
|
29
|
+
if not (data / name).exists():
|
|
30
|
+
(data / name).write_text(json.dumps(empty, indent=1) + "\n", encoding="utf-8")
|
|
31
|
+
gi = root / ".gitignore"
|
|
32
|
+
line = "weaponlab/work/"
|
|
33
|
+
if not gi.exists() or line not in gi.read_text(encoding="utf-8"):
|
|
34
|
+
with open(gi, "a", encoding="utf-8") as f:
|
|
35
|
+
f.write(line + "\n")
|
|
36
|
+
return {"project": str(root), "next": "set probe.command in weaponlab.json (docs/PROBE_CONTRACT.md), list weapons in weaponlab/data/weapons.json"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv=None):
|
|
40
|
+
a = list(sys.argv[1:] if argv is None else argv)
|
|
41
|
+
if not a or a[0] in ("-h", "--help"):
|
|
42
|
+
print(__doc__)
|
|
43
|
+
return 0
|
|
44
|
+
cmd = a[0]
|
|
45
|
+
if cmd == "serve":
|
|
46
|
+
from .server import main as serve
|
|
47
|
+
serve()
|
|
48
|
+
return 0
|
|
49
|
+
if cmd == "contract":
|
|
50
|
+
from . import contract as C
|
|
51
|
+
bad = C.check_file(a[1], a[2], a[3] if len(a) > 3 else None)
|
|
52
|
+
print(json.dumps({"ok": not bad, "problems": bad}, indent=1))
|
|
53
|
+
return 0 if not bad else 1
|
|
54
|
+
if cmd == "init":
|
|
55
|
+
out = init(a[1] if len(a) > 1 else ".")
|
|
56
|
+
elif cmd == "gate":
|
|
57
|
+
from . import gate as G
|
|
58
|
+
if a[1] == "run":
|
|
59
|
+
out = G.run_providers(_csv(a[2]), _csv(a[3]) if len(a) > 3 else None)
|
|
60
|
+
elif a[1] == "verdict":
|
|
61
|
+
out = G.verdict(a[2], _csv(a[3]) if len(a) > 3 else None)
|
|
62
|
+
elif a[1] == "baseline":
|
|
63
|
+
out = G.set_baseline(a[2])
|
|
64
|
+
else:
|
|
65
|
+
raise SystemExit(__doc__)
|
|
66
|
+
elif cmd == "rig" and a[1] == "fist":
|
|
67
|
+
from . import rig as R
|
|
68
|
+
out = R.fist(a[2])
|
|
69
|
+
elif cmd == "motion" and a[1] == "profile":
|
|
70
|
+
from . import motion as M
|
|
71
|
+
out = M.profile(a[2], *(a[3:4] or ["r"]))
|
|
72
|
+
else:
|
|
73
|
+
raise SystemExit(__doc__)
|
|
74
|
+
print(json.dumps(out, indent=1, default=str))
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
sys.exit(main())
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Rig Lab's renders: reads the probe's parts dumps (OBJ in the gun's space, cm, one
|
|
2
|
+
group a component and bone: "<role>|<component>|<asset>|<bone>"), splits each group into its pieces (connected
|
|
3
|
+
triangles), lists them, finds pieces drawn twice, and renders dev views from outside the gun. Writes JSON and PNG
|
|
4
|
+
only under the folder given.
|
|
5
|
+
|
|
6
|
+
blender -b --factory-startup -P render_parts.py -- <job.json>
|
|
7
|
+
|
|
8
|
+
job: {"out": folder, "dumps": [obj paths], "render": true, "views": ["right", "left", "top", "bottom"]}"""
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import bmesh
|
|
15
|
+
import bpy
|
|
16
|
+
from mathutils import Matrix, Vector
|
|
17
|
+
|
|
18
|
+
COLORS = {"arms": (0.75, 0.72, 0.68, 1), "gun": (0.12, 0.12, 0.14, 1), "part": (0.15, 0.3, 0.75, 1), "item": (0.95, 0.5, 0.1, 1)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_obj(path):
|
|
22
|
+
"""The groups of a parts dump: {group: [triangles of three (x, y, z) in cm]}, and the eye."""
|
|
23
|
+
groups, verts, cur, eye = {}, [], None, None
|
|
24
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
25
|
+
for line in f:
|
|
26
|
+
if line.startswith("v "):
|
|
27
|
+
verts.append(tuple(float(x) for x in line.split()[1:4]))
|
|
28
|
+
elif line.startswith("g "):
|
|
29
|
+
cur = line[2:].strip()
|
|
30
|
+
groups.setdefault(cur, [])
|
|
31
|
+
elif line.startswith("f "):
|
|
32
|
+
i = [int(x) - 1 for x in line.split()[1:4]]
|
|
33
|
+
groups[cur].append((verts[i[0]], verts[i[1]], verts[i[2]]))
|
|
34
|
+
elif line.startswith("# eye"):
|
|
35
|
+
eye = tuple(float(x) for x in line.split()[2:5])
|
|
36
|
+
return groups, eye
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pieces(tris, weld=0.02):
|
|
40
|
+
"""Connected pieces of a triangle soup (vertices within weld cm count as one)."""
|
|
41
|
+
key = lambda p: (round(p[0] / weld), round(p[1] / weld), round(p[2] / weld))
|
|
42
|
+
parent = {}
|
|
43
|
+
|
|
44
|
+
def find(a):
|
|
45
|
+
while parent[a] != a:
|
|
46
|
+
parent[a] = parent[parent[a]]
|
|
47
|
+
a = parent[a]
|
|
48
|
+
return a
|
|
49
|
+
|
|
50
|
+
for t in tris:
|
|
51
|
+
ks = [key(p) for p in t]
|
|
52
|
+
for k in ks:
|
|
53
|
+
parent.setdefault(k, k)
|
|
54
|
+
for k in ks[1:]:
|
|
55
|
+
ra, rb = find(ks[0]), find(k)
|
|
56
|
+
if ra != rb:
|
|
57
|
+
parent[rb] = ra
|
|
58
|
+
out = {}
|
|
59
|
+
for t in tris:
|
|
60
|
+
out.setdefault(find(key(t[0])), []).append(t)
|
|
61
|
+
return list(out.values())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def describe(tris):
|
|
65
|
+
pts = [Vector(p) for t in tris for p in t]
|
|
66
|
+
lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
|
|
67
|
+
hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
|
|
68
|
+
c = sum(pts, Vector()) / len(pts)
|
|
69
|
+
area = sum(((Vector(t[1]) - Vector(t[0])).cross(Vector(t[2]) - Vector(t[0]))).length / 2.0 for t in tris)
|
|
70
|
+
# the shape's own size, whatever its turn: its three principal extents (a signature for twins)
|
|
71
|
+
import numpy as np
|
|
72
|
+
a = np.array([[p.x, p.y, p.z] for p in pts])
|
|
73
|
+
a = a - a.mean(axis=0)
|
|
74
|
+
w, v = np.linalg.eigh(np.cov(a.T)) if len(a) > 3 else (np.zeros(3), np.eye(3))
|
|
75
|
+
proj = a @ v
|
|
76
|
+
ext = sorted((proj.max(axis=0) - proj.min(axis=0)).tolist(), reverse=True)
|
|
77
|
+
axis = v[:, int(np.argmax(w))].tolist()
|
|
78
|
+
return {"triangles": len(tris), "center": [round(x, 2) for x in c], "min": [round(x, 2) for x in lo], "max": [round(x, 2) for x in hi],
|
|
79
|
+
"area_cm2": round(area, 1), "extents": [round(x, 2) for x in ext], "long_axis": [round(x, 3) for x in axis]}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def twins(items, tol=0.06):
|
|
83
|
+
"""Pieces drawn twice: the same triangle count and the same three extents (within tol of the size), anywhere."""
|
|
84
|
+
found = []
|
|
85
|
+
for i in range(len(items)):
|
|
86
|
+
for j in range(i + 1, len(items)):
|
|
87
|
+
a, b = items[i], items[j]
|
|
88
|
+
if a["triangles"] < 12 or a["triangles"] != b["triangles"]:
|
|
89
|
+
continue
|
|
90
|
+
if all(abs(x - y) <= tol * max(x, y, 0.5) for x, y in zip(a["extents"], b["extents"])):
|
|
91
|
+
found.append({"a": a["id"], "b": b["id"], "apart_cm": round((Vector(a["center"]) - Vector(b["center"])).length, 2)})
|
|
92
|
+
return found
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def scene(groups):
|
|
96
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
97
|
+
for g, tris in groups.items():
|
|
98
|
+
if not tris:
|
|
99
|
+
continue
|
|
100
|
+
me = bpy.data.meshes.new("m")
|
|
101
|
+
bm = bmesh.new()
|
|
102
|
+
for t in tris:
|
|
103
|
+
vs = [bm.verts.new(Vector(p) / 100.0) for p in t]
|
|
104
|
+
try:
|
|
105
|
+
bm.faces.new(vs)
|
|
106
|
+
except ValueError:
|
|
107
|
+
pass
|
|
108
|
+
bm.to_mesh(me)
|
|
109
|
+
bm.free()
|
|
110
|
+
ob = bpy.data.objects.new(g[:60], me)
|
|
111
|
+
ob.color = COLORS.get(g.split("|")[0], (0.5, 0.5, 0.5, 1))
|
|
112
|
+
bpy.context.scene.collection.objects.link(ob)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def render(out, tag, views):
|
|
116
|
+
sc = bpy.context.scene
|
|
117
|
+
sc.render.engine = 'BLENDER_WORKBENCH'
|
|
118
|
+
sc.display.shading.color_type = 'OBJECT'
|
|
119
|
+
sc.display.shading.light = 'STUDIO'
|
|
120
|
+
sc.render.resolution_x, sc.render.resolution_y = 960, 600
|
|
121
|
+
sc.render.film_transparent = False
|
|
122
|
+
sc.world = bpy.data.worlds.new("w")
|
|
123
|
+
sc.world.color = (0.93, 0.93, 0.9)
|
|
124
|
+
pts = [o.matrix_world @ Vector(c) for o in sc.objects if o.type == 'MESH' for c in o.bound_box]
|
|
125
|
+
lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
|
|
126
|
+
hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
|
|
127
|
+
mid, size = (lo + hi) / 2, max((hi - lo).length, 0.1)
|
|
128
|
+
cam_data = bpy.data.cameras.new("c")
|
|
129
|
+
cam_data.type = 'ORTHO'
|
|
130
|
+
cam_data.ortho_scale = size * 1.05
|
|
131
|
+
cam = bpy.data.objects.new("c", cam_data)
|
|
132
|
+
sc.collection.objects.link(cam)
|
|
133
|
+
sc.camera = cam
|
|
134
|
+
# gun space: +Y along the barrel, +Z up, +X the gun's left (Pose Lab's gun frame)
|
|
135
|
+
dirs = {"right": Vector((-1, 0, 0)), "left": Vector((1, 0, 0)), "top": Vector((0, 0, 1)), "bottom": Vector((0, 0, -1)), "front": Vector((0, 1, 0))}
|
|
136
|
+
made = []
|
|
137
|
+
for v in views:
|
|
138
|
+
d = dirs[v]
|
|
139
|
+
cam.location = mid + d * size * 2
|
|
140
|
+
up = Vector((0, 1, 0)) if v in ("top", "bottom") else Vector((0, 0, 1))
|
|
141
|
+
cam.rotation_euler = (-d).to_track_quat('-Z', 'Y' if v not in ("top", "bottom") else 'Y').to_euler()
|
|
142
|
+
if v in ("top", "bottom"):
|
|
143
|
+
cam.rotation_euler = (-d).to_track_quat('-Z', 'Y').to_euler()
|
|
144
|
+
sc.render.filepath = os.path.join(out, "%s_%s.png" % (tag, v))
|
|
145
|
+
bpy.ops.render.render(write_still=True)
|
|
146
|
+
made.append(sc.render.filepath)
|
|
147
|
+
return made
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main():
|
|
151
|
+
job = json.load(open(sys.argv[sys.argv.index("--") + 1], encoding="utf-8"))
|
|
152
|
+
out = job["out"]
|
|
153
|
+
os.makedirs(out, exist_ok=True)
|
|
154
|
+
result = {}
|
|
155
|
+
for path in job["dumps"]:
|
|
156
|
+
tag = os.path.splitext(os.path.basename(path))[0]
|
|
157
|
+
groups, eye = read_obj(path)
|
|
158
|
+
items = []
|
|
159
|
+
for g, tris in groups.items():
|
|
160
|
+
role, comp, asset, bone = (g.split("|") + ["", "", "", ""])[:4]
|
|
161
|
+
if role == "arms":
|
|
162
|
+
continue
|
|
163
|
+
for k, pc in enumerate(sorted(pieces(tris), key=len, reverse=True)):
|
|
164
|
+
d = describe(pc)
|
|
165
|
+
d.update({"id": "%s|%s#%d" % (comp, bone, k), "role": role, "component": comp, "asset": asset, "bone": bone})
|
|
166
|
+
items.append(d)
|
|
167
|
+
result[tag] = {"eye": eye, "pieces": items, "twins": twins(items)}
|
|
168
|
+
if job.get("render"):
|
|
169
|
+
scene(groups)
|
|
170
|
+
result[tag]["renders"] = render(out, tag, job.get("views", ["right", "left", "top", "bottom"]))
|
|
171
|
+
json.dump(result, open(os.path.join(out, "riglab.json"), "w", encoding="utf-8"), indent=1)
|
|
172
|
+
print("RIGLAB DONE %d dumps" % len(result))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Motion Lab: a forearm's roll. Turns a hand about its forearm's line through a whole clip, the hand's place kept,
|
|
2
|
+
the roll shared out the way a real forearm shares it: the twist bones take a third and two thirds, the hand all of it
|
|
3
|
+
(the radius turns about the ulna along the whole forearm, not at the wrist). A palm-down fist turned a quarter turn
|
|
4
|
+
stands thumb up, so a hilt in it rises the way a sword's does. Bones as Unreal's mannequin names them (lowerarm_r,
|
|
5
|
+
lowerarm_twist_01_r, lowerarm_twist_02_r, hand_r, index_01_r, pinky_01_r).
|
|
6
|
+
|
|
7
|
+
blender -b --factory-startup -P roll.py -- <in.fbx> <out.fbx> <side l|r> <degrees | auto | measure>
|
|
8
|
+
|
|
9
|
+
auto: the turn that stands the knuckle line (index to little finger) most nearly upright at the clip's first frame
|
|
10
|
+
(each clip from its own start: a backhand cut can start palm up while the others start palm down).
|
|
11
|
+
measure: writes nothing; prints MOTIONLAB PROFILE, the knuckle line's angle about the forearm every few frames
|
|
12
|
+
(0: thumb up; past 80 either way breaks the forearm's range).
|
|
13
|
+
Prints MOTIONLAB ROLL <degrees>."""
|
|
14
|
+
import math
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import bpy
|
|
18
|
+
from mathutils import Matrix, Vector
|
|
19
|
+
|
|
20
|
+
args = sys.argv[sys.argv.index("--") + 1:]
|
|
21
|
+
src, out, side, want = args[:4]
|
|
22
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
23
|
+
bpy.ops.import_scene.fbx(filepath=src)
|
|
24
|
+
arm = [o for o in bpy.data.objects if o.type == 'ARMATURE'][0]
|
|
25
|
+
act = arm.animation_data.action
|
|
26
|
+
sc = bpy.context.scene
|
|
27
|
+
f0, f1 = int(act.frame_range[0]), int(act.frame_range[1])
|
|
28
|
+
P = arm.pose.bones
|
|
29
|
+
lower, hand = P["lowerarm_" + side], P["hand_" + side]
|
|
30
|
+
twists = [(P[n], k) for n, k in (("lowerarm_twist_01_" + side, 1 / 3), ("lowerarm_twist_02_" + side, 2 / 3)) if n in P]
|
|
31
|
+
for b, _ in twists:
|
|
32
|
+
assert b.parent == lower, "%s is not the forearm's child" % b.name
|
|
33
|
+
assert hand.parent == lower, "the hand is not the forearm's child"
|
|
34
|
+
W = arm.matrix_world
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def axis_now():
|
|
38
|
+
a = (W @ hand.head) - (W @ lower.head)
|
|
39
|
+
return a.normalized()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def turned(pb, ang, axis):
|
|
43
|
+
"""pb's armature-space matrix turned by ang about the forearm's line (through pb's head)."""
|
|
44
|
+
ax = W.to_3x3().inverted() @ axis
|
|
45
|
+
head = pb.matrix.translation.copy()
|
|
46
|
+
return Matrix.Translation(head) @ Matrix.Rotation(ang, 4, ax.normalized()) @ Matrix.Translation(-head) @ pb.matrix
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def profile():
|
|
50
|
+
out = []
|
|
51
|
+
for f in range(f0, f1 + 1, max(1, (f1 - f0) // 12)):
|
|
52
|
+
sc.frame_set(f)
|
|
53
|
+
ax = axis_now()
|
|
54
|
+
k = (W @ P["index_01_" + side].head) - (W @ P["pinky_01_" + side].head)
|
|
55
|
+
k = (k - ax * k.dot(ax)).normalized()
|
|
56
|
+
up = Vector((0, 0, 1))
|
|
57
|
+
up = (up - ax * up.dot(ax)).normalized()
|
|
58
|
+
out.append((f, round(math.degrees(math.atan2(k.dot(ax.cross(up)), k.dot(up))))))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if want == "measure":
|
|
63
|
+
pr = profile()
|
|
64
|
+
print("MOTIONLAB PROFILE %s" % " ".join("%d:%d" % fa for fa in pr))
|
|
65
|
+
print("MOTIONLAB PROFILE WORST %d" % max(abs(a) for _, a in pr))
|
|
66
|
+
sys.exit(0)
|
|
67
|
+
sc.frame_set(f0)
|
|
68
|
+
if want == "auto":
|
|
69
|
+
axis = axis_now()
|
|
70
|
+
across = (W @ P["index_01_" + side].head) - (W @ P["pinky_01_" + side].head)
|
|
71
|
+
best = max(range(-180, 181), key=lambda d: (Matrix.Rotation(math.radians(d), 3, axis) @ across).normalized().z)
|
|
72
|
+
deg = float(best)
|
|
73
|
+
else:
|
|
74
|
+
deg = float(want)
|
|
75
|
+
print("MOTIONLAB ROLL %.1f" % deg)
|
|
76
|
+
frames = {}
|
|
77
|
+
for f in range(f0, f1 + 1):
|
|
78
|
+
sc.frame_set(f)
|
|
79
|
+
axis = axis_now()
|
|
80
|
+
frames[f] = [(pb, turned(pb, math.radians(deg) * k, axis)) for pb, k in twists + [(hand, 1.0)]]
|
|
81
|
+
for f, mats in frames.items():
|
|
82
|
+
sc.frame_set(f)
|
|
83
|
+
for pb, m in mats:
|
|
84
|
+
pb.rotation_mode = 'QUATERNION'
|
|
85
|
+
pb.matrix = m
|
|
86
|
+
bpy.context.view_layer.update()
|
|
87
|
+
pb.keyframe_insert("location", frame=f)
|
|
88
|
+
pb.keyframe_insert("rotation_quaternion", frame=f)
|
|
89
|
+
# check: the hand's place kept, the knuckle line turned
|
|
90
|
+
sc.frame_set(f0)
|
|
91
|
+
print("MOTIONLAB ROLL CHECK knuckles up %.2f" % ((W @ P["index_01_" + side].head) - (W @ P["pinky_01_" + side].head)).normalized().z)
|
|
92
|
+
sc.frame_start, sc.frame_end = f0, f1
|
|
93
|
+
for o in bpy.context.scene.objects:
|
|
94
|
+
o.select_set(o == arm)
|
|
95
|
+
bpy.ops.export_scene.fbx(filepath=out, use_selection=True, object_types={'ARMATURE'}, add_leaf_bones=False, bake_anim=True,
|
|
96
|
+
bake_anim_use_all_actions=False, bake_anim_use_nla_strips=False, bake_anim_force_startend_keying=True,
|
|
97
|
+
bake_anim_simplify_factor=0.0)
|
|
98
|
+
print("MOTIONLAB ROLL DONE", out, "armature", arm.name, "frames", f0, f1)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Motion Lab: a sliding part's track on its axis. A gun clip (the editor's FBX) whose part
|
|
2
|
+
(a carrier, a bolt, a pump) leaves its line as it travels gets its track rebuilt: its seat is the bind pose (the
|
|
3
|
+
model as built, the part in its rails), it travels only along its axis, by the clip's own travel along that axis,
|
|
4
|
+
frame by frame, and it does not turn. Writes the clip as FBX for the editor.
|
|
5
|
+
|
|
6
|
+
blender -b --factory-startup -P track.py -- <in.fbx> <out.fbx> <bone> <root bone name> [x,y,z] [seat x,y,z]
|
|
7
|
+
|
|
8
|
+
The seat: where the part rests, in its own space (pose location); the bind pose if left out. A part can rest away
|
|
9
|
+
from its bind pose (a carrier 1.5 cm off it in one rifle): take the seat from the idle clip then.
|
|
10
|
+
|
|
11
|
+
The axis: x,y,z in the model's space as Blender reads the FBX (the bore: Rig Lab's barrel piece), turned into the
|
|
12
|
+
part's own space; without it, the clip's own stroke (which keeps a slanted path slanted)."""
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import bpy
|
|
16
|
+
from mathutils import Quaternion, Vector
|
|
17
|
+
|
|
18
|
+
args = sys.argv[sys.argv.index("--") + 1:]
|
|
19
|
+
src, out, bone, root = args[:4]
|
|
20
|
+
given = Vector([float(x) for x in args[4].split(",")]) if len(args) > 4 else None
|
|
21
|
+
seat = Vector([float(x) for x in args[5].split(",")]) if len(args) > 5 else Vector((0, 0, 0))
|
|
22
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
23
|
+
bpy.ops.import_scene.fbx(filepath=src)
|
|
24
|
+
arm = [o for o in bpy.data.objects if o.type == 'ARMATURE'][0]
|
|
25
|
+
act = arm.animation_data.action
|
|
26
|
+
sc = bpy.context.scene
|
|
27
|
+
f0, f1 = int(act.frame_range[0]), int(act.frame_range[1])
|
|
28
|
+
pb = arm.pose.bones[bone]
|
|
29
|
+
pb.rotation_mode = 'QUATERNION'
|
|
30
|
+
locs = {}
|
|
31
|
+
for f in range(f0, f1 + 1):
|
|
32
|
+
sc.frame_set(f)
|
|
33
|
+
locs[f] = pb.location.copy()
|
|
34
|
+
first = locs[f0]
|
|
35
|
+
far = max(locs, key=lambda f: (locs[f] - first).length)
|
|
36
|
+
axis = (locs[far] - first)
|
|
37
|
+
stroke = axis.length
|
|
38
|
+
axis = axis.normalized() if stroke > 1e-6 else Vector((0, 0, 1))
|
|
39
|
+
if given is not None:
|
|
40
|
+
# the model's axis in the bone's rest frame (pose locations are in it), signed as the stroke goes
|
|
41
|
+
g = (arm.matrix_world.to_3x3() @ pb.bone.matrix_local.to_3x3()).inverted() @ given
|
|
42
|
+
g.normalize()
|
|
43
|
+
axis = g if g.dot(axis) >= 0 else -g
|
|
44
|
+
# the clip's own travel along that axis (its off-axis part, and the offset it starts from, dropped)
|
|
45
|
+
travel = {f: (locs[f] - first).dot(axis) for f in locs}
|
|
46
|
+
off = max(((locs[f] - first) - axis * travel[f]).length for f in locs)
|
|
47
|
+
print("MOTIONLAB TRACK %s: frames %d-%d, stroke %.3f along %s (local), started %s off its bind seat, most off-axis %.3f"
|
|
48
|
+
% (bone, f0, f1, stroke, tuple(round(x, 3) for x in axis), tuple(round(x, 3) for x in first), off))
|
|
49
|
+
# rebuild the track: drop the old keys of this bone, key the new ones
|
|
50
|
+
try: # Blender 4.4+: an action's curves live in its slot's channel bag
|
|
51
|
+
from bpy_extras import anim_utils
|
|
52
|
+
curves = anim_utils.action_get_channelbag_for_slot(act, arm.animation_data.action_slot).fcurves
|
|
53
|
+
except (ImportError, AttributeError):
|
|
54
|
+
curves = act.fcurves
|
|
55
|
+
for fc in [fc for fc in curves if fc.data_path.startswith('pose.bones["%s"]' % bone)]:
|
|
56
|
+
curves.remove(fc)
|
|
57
|
+
for f in range(f0, f1 + 1):
|
|
58
|
+
pb.location = seat + axis * travel[f]
|
|
59
|
+
pb.rotation_quaternion = Quaternion((1, 0, 0, 0))
|
|
60
|
+
pb.scale = Vector((1, 1, 1))
|
|
61
|
+
pb.keyframe_insert("location", frame=f)
|
|
62
|
+
pb.keyframe_insert("rotation_quaternion", frame=f)
|
|
63
|
+
pb.keyframe_insert("scale", frame=f)
|
|
64
|
+
sc.frame_start, sc.frame_end = f0, f1 # the bake writes the scene's range: the clip's own
|
|
65
|
+
for o in bpy.context.scene.objects:
|
|
66
|
+
o.select_set(o == arm)
|
|
67
|
+
clash = bpy.data.objects.get(root)
|
|
68
|
+
if clash and clash != arm:
|
|
69
|
+
clash.name = clash.name + "_kept"
|
|
70
|
+
arm.name = root
|
|
71
|
+
bpy.ops.export_scene.fbx(filepath=out, use_selection=True, object_types={'ARMATURE'}, add_leaf_bones=False, bake_anim=True,
|
|
72
|
+
bake_anim_use_all_actions=False, bake_anim_use_nla_strips=False, bake_anim_force_startend_keying=True,
|
|
73
|
+
bake_anim_simplify_factor=0.0)
|
|
74
|
+
print("MOTIONLAB TRACK DONE", out)
|
weaponlab/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""A Weapon Lab project: one folder with a weaponlab.json. WEAPONLAB_PROJECT names it; otherwise the current folder.
|
|
2
|
+
|
|
3
|
+
weaponlab.json (every key optional):
|
|
4
|
+
{"data": "weaponlab/data", weapons.json, metrics.json, cases.json, budgets.json, actions.json (in git)
|
|
5
|
+
"specs": "weaponlab/specs", one <weapon>.json a weapon (in git)
|
|
6
|
+
"work": "weaponlab/work", runs, baseline, evals, proposals, dumps, shots (not in git)
|
|
7
|
+
"probe": {"command": ["MyGame.exe", "-probe={job}", "-log={work}/probe/game-{stamp}.log"],
|
|
8
|
+
"timeout_s": 1800,
|
|
9
|
+
"busy": ["UnrealEditor.exe"], wait while one runs (another job), up to wait_s
|
|
10
|
+
"refuse": ["MyGame.exe"], fail at once while one runs (the player's own game)
|
|
11
|
+
"wait_s": 0, "warmup": true, "low_priority": false,
|
|
12
|
+
"job_extra": {"quit": true}}, keys added to every job, for the probe's own use
|
|
13
|
+
"providers": {"poselab": ["python", "-m", "my_poselab_rows"]}, row sources the Gate may run (a name here
|
|
14
|
+
replaces the built-in probe or rig)
|
|
15
|
+
"blender": "C:/Program Files/Blender Foundation/Blender 5.2/blender.exe"}
|
|
16
|
+
|
|
17
|
+
Relative paths are from the project folder. Nothing else in Weapon Lab knows where a project lives."""
|
|
18
|
+
import glob
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
DEFAULTS = {"data": "weaponlab/data", "specs": "weaponlab/specs", "work": "weaponlab/work",
|
|
26
|
+
"probe": {"command": [], "timeout_s": 1800, "busy": [], "refuse": [], "wait_s": 0, "warmup": True,
|
|
27
|
+
"low_priority": False, "job_extra": {}},
|
|
28
|
+
"providers": {}, "blender": None}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Project:
|
|
32
|
+
def __init__(self, root=None):
|
|
33
|
+
self.root = Path(root or os.environ.get("WEAPONLAB_PROJECT") or os.getcwd()).resolve()
|
|
34
|
+
cfg = self.root / "weaponlab.json"
|
|
35
|
+
raw = json.loads(cfg.read_text(encoding="utf-8-sig")) if cfg.exists() else {}
|
|
36
|
+
self.config = {**DEFAULTS, **raw, "probe": {**DEFAULTS["probe"], **raw.get("probe", {})}}
|
|
37
|
+
self.has_config = cfg.exists()
|
|
38
|
+
|
|
39
|
+
def _dir(self, key):
|
|
40
|
+
p = Path(self.config[key])
|
|
41
|
+
return p if p.is_absolute() else self.root / p
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def data(self):
|
|
45
|
+
return self._dir("data")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def specs(self):
|
|
49
|
+
return self._dir("specs")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def work(self):
|
|
53
|
+
return self._dir("work")
|
|
54
|
+
|
|
55
|
+
def ensure(self, *parts):
|
|
56
|
+
p = self.work.joinpath(*parts)
|
|
57
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def probe(self):
|
|
62
|
+
return self.config["probe"]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def providers(self):
|
|
66
|
+
return self.config.get("providers") or {}
|
|
67
|
+
|
|
68
|
+
def blender(self):
|
|
69
|
+
"""Blender: WEAPONLAB_BLENDER, the project's setting, the PATH, then the usual install folders."""
|
|
70
|
+
for cand in (os.environ.get("WEAPONLAB_BLENDER"), self.config.get("blender"), shutil.which("blender")):
|
|
71
|
+
if cand and Path(cand).exists():
|
|
72
|
+
return Path(cand)
|
|
73
|
+
pats = []
|
|
74
|
+
if sys.platform == "win32":
|
|
75
|
+
for base in (os.environ.get("ProgramFiles", r"C:\Program Files"), r"D:\Blender", r"C:\Blender"):
|
|
76
|
+
pats += [os.path.join(base, "Blender Foundation", "Blender*", "blender.exe"), os.path.join(base, "blender.exe")]
|
|
77
|
+
elif sys.platform == "darwin":
|
|
78
|
+
pats += ["/Applications/Blender.app/Contents/MacOS/Blender"]
|
|
79
|
+
else:
|
|
80
|
+
pats += ["/usr/bin/blender", "/snap/bin/blender", os.path.expanduser("~/blender*/blender")]
|
|
81
|
+
for pat in pats:
|
|
82
|
+
hits = sorted(glob.glob(pat), reverse=True)
|
|
83
|
+
if hits:
|
|
84
|
+
return Path(hits[0])
|
|
85
|
+
raise FileNotFoundError("Blender not found: set WEAPONLAB_BLENDER or \"blender\" in weaponlab.json")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_current = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def project():
|
|
92
|
+
"""The project in use (read once; reset() after changing WEAPONLAB_PROJECT)."""
|
|
93
|
+
global _current
|
|
94
|
+
if _current is None:
|
|
95
|
+
_current = Project()
|
|
96
|
+
return _current
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def reset(root=None):
|
|
100
|
+
global _current
|
|
101
|
+
_current = Project(root) if root else None
|
|
102
|
+
return _current
|