mirror of
https://github.com/FauxxYT/StreamXR.git
synced 2026-10-23 10:00:43 +02:00
104 lines
5.0 KiB
Python
104 lines
5.0 KiB
Python
"""Which CAD face of the Steam Frame controller belongs to which part (button, trigger...).
|
|
|
|
Valve's STEP files are one fused solid per controller with no colours, so parts are found
|
|
from the geometry of each CAD face. Coordinates are Valve's CAD frame in millimetres:
|
|
the faceplate faces +Z, the handle runs toward -Y/-Z, the trigger sits at +Y under the
|
|
faceplate. The rules are written for the right controller; the left is mirrored in X.
|
|
|
|
The trigger and grip button were picked by hand as CAD face ids on the right controller
|
|
(they share their outline with the body, so simple rules didn't separate them cleanly);
|
|
the left controller's faces are found by mirroring those and matching the nearest face.
|
|
If Valve publishes a new CAD revision, re-check these with a render before trusting them.
|
|
"""
|
|
import json
|
|
|
|
import numpy as np
|
|
|
|
# Order matters: the index is what models.js stores per vertex, and the stick must be 1
|
|
# (the shader tilts part 1). Keep in sync with src/core/page/controllers/config.js colours.
|
|
PARTS = ['body', 'stick', 'btnA', 'btnB', 'btnX', 'btnY', 'dpad', 'menu', 'system', 'trigger', 'grip', 'door']
|
|
P = {name: i for i, name in enumerate(PARTS)}
|
|
|
|
# Right controller CAD face ids (from the 2026-09-26 STEP release).
|
|
GRIP_FACES_R = [25, 26, 29, 30, 31, 32, 464, 466, 467, 468, 469, 474, 482, 483]
|
|
TRIGGER_FACES_R = [392, 393, 394, 395, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410,
|
|
411, 412, 413, 414, 415, 416, 417, 418, 423, 424, 425, 426, 427, 429, 430, 431, 433, 434,
|
|
435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450]
|
|
|
|
|
|
def load_mesh(path):
|
|
"""occt-import-js JSON -> (vertices, normals, triangles, CAD face id per triangle)."""
|
|
m = json.load(open(path))['meshes'][0]
|
|
v = np.array(m['attributes']['position']['array'], float).reshape(-1, 3)
|
|
n = np.array(m['attributes']['normal']['array'], float).reshape(-1, 3)
|
|
tris = np.array(m['index']['array'], int).reshape(-1, 3)
|
|
face = np.full(len(tris), -1)
|
|
for k, bf in enumerate(m['brep_faces']):
|
|
face[bf['first']:bf['last'] + 1] = k
|
|
return v, n, tris, face
|
|
|
|
|
|
def face_stats(v, tris, face):
|
|
"""Area-weighted centroid, average normal and area of every CAD face."""
|
|
t = v[tris]
|
|
c = t.mean(1)
|
|
cr = np.cross(t[:, 1] - t[:, 0], t[:, 2] - t[:, 0])
|
|
area = np.linalg.norm(cr, axis=1) / 2
|
|
F = face.max() + 1
|
|
fc, fn, fa = np.zeros((F, 3)), np.zeros((F, 3)), np.zeros(F)
|
|
np.add.at(fc, face, c * area[:, None])
|
|
np.add.at(fn, face, cr)
|
|
np.add.at(fa, face, area)
|
|
fc /= np.maximum(fa, 1e-9)[:, None]
|
|
fn /= np.linalg.norm(fn, axis=1, keepdims=True) + 1e-9
|
|
return fc, fn, fa
|
|
|
|
|
|
def _faceplate_parts(fc, fa, side):
|
|
"""Rule-based parts on the faceplate (everything except trigger and grip)."""
|
|
s = 1 if side == 'R' else -1
|
|
x, y, z = fc[:, 0] * s, fc[:, 1], fc[:, 2] # mirrored into the right-hand frame
|
|
out = np.zeros(len(fc), int) # everything starts as 'body' (index 0)
|
|
near = lambda cx, cy: np.hypot(x - cx, y - cy) # distance from a point on the faceplate
|
|
# Each rule: faces whose centre is within a radius of a spot (or inside a box) on the
|
|
# faceplate, and high enough in z to be on the surface rather than inside the shell.
|
|
out[(near(-13.1, -10.2) < 6.5) & (z > -1.0)] = P['stick']
|
|
if side == 'R': # diamond of four face buttons
|
|
for name, (cx, cy) in {'btnA': (14.9, -12.1), 'btnB': (24.0, -3.2),
|
|
'btnX': (6.0, -3.2), 'btnY': (14.9, 5.7)}.items():
|
|
out[(near(cx, cy) < 4.3) & (z > -1.7)] = P[name]
|
|
else: # D-pad
|
|
out[(np.abs(x - 14.9) < 12.5) & (np.abs(y + 3.2) < 12.5) & (z > -1.0) & (fa < 500)] = P['dpad']
|
|
out[(np.abs(x + 0.6) < 5.6) & (np.abs(y - 9.4) < 3.0) & (z > -1.0)] = P['menu']
|
|
out[(near(3.8, -23.7) < 4.7) & (z > -3.0) & (fa < 100)] = P['system']
|
|
return out
|
|
|
|
|
|
def _mirror_match(ids, fc, fa, ref):
|
|
"""Right-controller face ids -> matching face ids on the left controller."""
|
|
rc, ra = ref
|
|
mirrored = fc * np.array([-1, 1, 1])
|
|
out = []
|
|
for k in ids:
|
|
d = np.linalg.norm(mirrored - rc[k], axis=1) + 0.02 * np.abs(fa - ra[k])
|
|
j = int(np.argmin(d))
|
|
if d[j] < 1.5:
|
|
out.append(j)
|
|
return out
|
|
|
|
|
|
def classify(fine_mesh_path, side, right_fine_mesh_path):
|
|
"""Part index for every CAD face of one controller. side: 'R' or 'L'."""
|
|
v, _, tris, face = load_mesh(fine_mesh_path)
|
|
fc, _, fa = face_stats(v, tris, face)
|
|
parts = _faceplate_parts(fc, fa, side)
|
|
if side == 'R':
|
|
grip, trigger = GRIP_FACES_R, TRIGGER_FACES_R
|
|
else:
|
|
rv, _, rt, rf = load_mesh(right_fine_mesh_path)
|
|
rc, _, ra = face_stats(rv, rt, rf)
|
|
grip, trigger = _mirror_match(GRIP_FACES_R, fc, fa, (rc, ra)), _mirror_match(TRIGGER_FACES_R, fc, fa, (rc, ra))
|
|
parts[trigger] = P['trigger']
|
|
parts[grip] = P['grip']
|
|
return parts
|