Skip to content

Commit 7cc9f80

Browse files
committed
Add direct angle manipulation controls
1 parent b4ef69c commit 7cc9f80

7 files changed

Lines changed: 662 additions & 2 deletions

File tree

packages/posecode-render/src/index.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ export interface Viewer {
111111
getConstraintDiagnostics(): readonly ConstraintDiagnostic[];
112112
/** Precise visible world bounds; intended for audits and deterministic export. */
113113
getVisibleBounds(): THREE.Box3;
114+
/**
115+
* Highlight canonical bone ids at their live joint positions. Unknown ids
116+
* are ignored; pass an empty list to clear the selection.
117+
*/
118+
selectBones(boneIds: readonly string[]): void;
114119
getMannequin(): any;
115120
getCharacter(): any;
116121
/**
@@ -179,6 +184,46 @@ export function createViewer(
179184
scene.background = new THREE.Color(0x0c0f15);
180185
scene.fog = new THREE.Fog(0x0c0f15, 9, 18);
181186

187+
// Text-editor selection overlay. Markers live outside the mannequin and
188+
// character trees so they never affect grounding, camera framing, bounds,
189+
// exports, or contact diagnostics.
190+
const boneSelection = new THREE.Group();
191+
boneSelection.name = "posecode-bone-selection";
192+
scene.add(boneSelection);
193+
const selectionDotGeometry = new THREE.SphereGeometry(0.018, 16, 12);
194+
const selectionRingGeometry = new THREE.TorusGeometry(0.055, 0.006, 8, 32);
195+
const selectionDotMaterial = new THREE.MeshBasicMaterial({
196+
color: 0xd4ff3f,
197+
transparent: true,
198+
opacity: 0.96,
199+
depthTest: false,
200+
depthWrite: false,
201+
});
202+
const selectionRingMaterial = new THREE.MeshBasicMaterial({
203+
color: 0xd4ff3f,
204+
transparent: true,
205+
opacity: 0.82,
206+
depthTest: false,
207+
depthWrite: false,
208+
});
209+
let selectedBoneIds: string[] = [];
210+
let selectionMarkers: THREE.Group[] = [];
211+
212+
function rebuildBoneSelection(): void {
213+
boneSelection.clear();
214+
selectionMarkers = selectedBoneIds.map(() => {
215+
const marker = new THREE.Group();
216+
marker.renderOrder = 1000;
217+
const dot = new THREE.Mesh(selectionDotGeometry, selectionDotMaterial);
218+
dot.renderOrder = 1000;
219+
const ring = new THREE.Mesh(selectionRingGeometry, selectionRingMaterial);
220+
ring.renderOrder = 1000;
221+
marker.add(dot, ring);
222+
boneSelection.add(marker);
223+
return marker;
224+
});
225+
}
226+
182227
// Image-based environment light: soft bounced light that gives the matte
183228
// figure materials realistic shading gradients instead of flat CG plastic.
184229
const pmrem = new THREE.PMREMGenerator(renderer);
@@ -911,6 +956,23 @@ export function createViewer(
911956
// geometry; a segmented skin can have a different lowest point as limbs
912957
// rotate. Reconcile the actual skinned surface after every animation layer.
913958
if (character && solvedInfo && isFloorBound(solvedInfo)) character.reconcileFloor();
959+
// Follow the visible rig (including mocap and its final floor correction)
960+
// when available; the congruent procedural driver is the fallback.
961+
for (let i = 0; i < selectedBoneIds.length; i++) {
962+
const boneId = selectedBoneIds[i]!;
963+
const marker = selectionMarkers[i]!;
964+
const position =
965+
character?.getJointWorldPosition(boneId) ??
966+
mannequin.bones.get(boneId)?.getWorldPosition(new THREE.Vector3()) ??
967+
null;
968+
marker.visible = position !== null;
969+
if (position) {
970+
marker.position.copy(position);
971+
// The torus is a screen-facing selection ring; the centre dot remains
972+
// spherical, so copying the camera frame works for both children.
973+
marker.quaternion.copy(camera.quaternion);
974+
}
975+
}
914976
frameDt = 0;
915977
if (easeCamera) {
916978
controls.target.lerp(desiredTarget, 0.07);
@@ -1242,6 +1304,12 @@ export function createViewer(
12421304
getVisibleBounds() {
12431305
return character?.getBounds() ?? new THREE.Box3().setFromObject(mannequin.root);
12441306
},
1307+
selectBones(boneIds) {
1308+
selectedBoneIds = [...new Set(boneIds)].filter((id) =>
1309+
mannequin.bones.has(id),
1310+
);
1311+
rebuildBoneSelection();
1312+
},
12451313
getMannequin() {
12461314
return mannequin;
12471315
},
@@ -1267,6 +1335,10 @@ export function createViewer(
12671335
clipLayer?.dispose();
12681336
character?.dispose();
12691337
floorGuide?.dispose();
1338+
selectionDotGeometry.dispose();
1339+
selectionRingGeometry.dispose();
1340+
selectionDotMaterial.dispose();
1341+
selectionRingMaterial.dispose();
12701342
renderer.dispose();
12711343
},
12721344
};

playground/play.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,15 @@
182182
<section class="viewer-pane">
183183
<canvas id="canvas"></canvas>
184184
<div class="viewer-veil" aria-hidden="true"></div>
185+
<div
186+
id="joint-selection"
187+
class="joint-selection"
188+
aria-live="polite"
189+
hidden
190+
>
191+
<span>selected</span>
192+
<strong id="joint-selection-name"></strong>
193+
</div>
185194

186195
<div class="hud">
187196
<div id="phase" class="phase"></div>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
ACTION_NAMES,
3+
JOINT_NAMES,
4+
expandJoint,
5+
romFor,
6+
} from "posecode-parser";
7+
8+
const JOINT_SET = new Set<string>(JOINT_NAMES);
9+
const ACTION_SET = new Set<string>(ACTION_NAMES);
10+
11+
/**
12+
* A directly-manipulable `<joint>: <action> <angle>` source line.
13+
* Positions are absolute CodeMirror document offsets and use half-open ranges.
14+
*/
15+
export interface AngleTarget {
16+
joint: string;
17+
action: string;
18+
degrees: number;
19+
jointFrom: number;
20+
jointTo: number;
21+
angleFrom: number;
22+
angleTo: number;
23+
}
24+
25+
export interface AngleRange {
26+
min: number;
27+
max: number;
28+
}
29+
30+
// Keep this deliberately stricter than syntax highlighting. Only complete,
31+
// parser-valid joint target lines become controls; comments, turn/travel
32+
// numbers, and half-written source remain ordinary editable text.
33+
const JOINT_TARGET =
34+
/^(\s*)([A-Za-z][\w-]*)(\s*:\s*)([A-Za-z][\w-]*)(\s+)(-?(?:\d+(?:\.\d*)?|\.\d+))(?=\s*(?:(?:#|\/\/).*)?$)/;
35+
36+
/** Locate every source angle that can safely become an inline control. */
37+
export function findAngleTargets(source: string): AngleTarget[] {
38+
const targets: AngleTarget[] = [];
39+
let lineFrom = 0;
40+
41+
for (const line of source.split(/\n/)) {
42+
const match = JOINT_TARGET.exec(line.replace(/\r$/, ""));
43+
if (match && JOINT_SET.has(match[2]!) && ACTION_SET.has(match[4]!)) {
44+
const joint = match[2]!;
45+
const action = match[4]!;
46+
// Unsupported joint/action pairs stay plain text so the parser error
47+
// remains the primary interaction rather than presenting a bogus range.
48+
if (angleRangeFor(joint, action)) {
49+
const jointFrom = lineFrom + match[1]!.length;
50+
const angleFrom =
51+
jointFrom +
52+
match[2]!.length +
53+
match[3]!.length +
54+
match[4]!.length +
55+
match[5]!.length;
56+
const angleText = match[6]!;
57+
targets.push({
58+
joint,
59+
action,
60+
degrees: Number(angleText),
61+
jointFrom,
62+
jointTo: jointFrom + joint.length,
63+
angleFrom,
64+
angleTo: angleFrom + angleText.length,
65+
});
66+
}
67+
}
68+
// split() removes the newline, so account for it between every pair.
69+
lineFrom += line.length + 1;
70+
}
71+
72+
return targets;
73+
}
74+
75+
/** Find the target whose joint or angle contains a document position. */
76+
export function angleTargetAt(
77+
source: string,
78+
position: number,
79+
part: "joint" | "angle",
80+
): AngleTarget | null {
81+
for (const target of findAngleTargets(source)) {
82+
const from = part === "joint" ? target.jointFrom : target.angleFrom;
83+
const to = part === "joint" ? target.jointTo : target.angleTo;
84+
if (position >= from && position < to) return target;
85+
}
86+
return null;
87+
}
88+
89+
/**
90+
* Return the ROM intersection for all bones represented by a DSL joint name.
91+
* Groups therefore get one honest range that is valid for every selected bone.
92+
*/
93+
export function angleRangeFor(joint: string, action: string): AngleRange | null {
94+
const bones = expandJoint(joint);
95+
const limits = bones
96+
.map((bone) => romFor(bone, action))
97+
.filter((limit): limit is AngleRange => limit !== null);
98+
if (limits.length === 0 || limits.length !== bones.length) return null;
99+
100+
const min = Math.max(...limits.map((limit) => limit.min));
101+
const max = Math.min(...limits.map((limit) => limit.max));
102+
return min <= max ? { min, max } : null;
103+
}
104+
105+
/** Clamp and format a spinner value without accumulating float noise. */
106+
export function normalizeAngle(value: number, range: AngleRange): string {
107+
const clamped = Math.min(range.max, Math.max(range.min, value));
108+
return String(Math.round(clamped * 10) / 10);
109+
}

0 commit comments

Comments
 (0)