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 ADDED
@@ -0,0 +1,3 @@
1
+ """Weapon Lab: find and fix first-person weapon bugs once per kind, then for every weapon, behind a gate. A game's
2
+ own probe measures (docs/PROBE_CONTRACT.md); the Gate judges; Rig Lab and Motion Lab read and mend."""
3
+ __version__ = "0.1.0"
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