Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/select-rendered-joints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posecode-render": patch
---

Add `Viewer.selectBones()` to highlight canonical bones at their live joint positions without affecting bounds, grounding, exports, or diagnostics.
72 changes: 72 additions & 0 deletions packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export interface Viewer {
getConstraintDiagnostics(): readonly ConstraintDiagnostic[];
/** Precise visible world bounds; intended for audits and deterministic export. */
getVisibleBounds(): THREE.Box3;
/**
* Highlight canonical bone ids at their live joint positions. Unknown ids
* are ignored; pass an empty list to clear the selection.
*/
selectBones(boneIds: readonly string[]): void;
getMannequin(): any;
getCharacter(): any;
/**
Expand Down Expand Up @@ -179,6 +184,46 @@ export function createViewer(
scene.background = new THREE.Color(0x0c0f15);
scene.fog = new THREE.Fog(0x0c0f15, 9, 18);

// Text-editor selection overlay. Markers live outside the mannequin and
// character trees so they never affect grounding, camera framing, bounds,
// exports, or contact diagnostics.
const boneSelection = new THREE.Group();
boneSelection.name = "posecode-bone-selection";
scene.add(boneSelection);
const selectionDotGeometry = new THREE.SphereGeometry(0.018, 16, 12);
const selectionRingGeometry = new THREE.TorusGeometry(0.055, 0.006, 8, 32);
const selectionDotMaterial = new THREE.MeshBasicMaterial({
color: 0xd4ff3f,
transparent: true,
opacity: 0.96,
depthTest: false,
depthWrite: false,
});
const selectionRingMaterial = new THREE.MeshBasicMaterial({
color: 0xd4ff3f,
transparent: true,
opacity: 0.82,
depthTest: false,
depthWrite: false,
});
let selectedBoneIds: string[] = [];
let selectionMarkers: THREE.Group[] = [];

function rebuildBoneSelection(): void {
boneSelection.clear();
selectionMarkers = selectedBoneIds.map(() => {
const marker = new THREE.Group();
marker.renderOrder = 1000;
const dot = new THREE.Mesh(selectionDotGeometry, selectionDotMaterial);
dot.renderOrder = 1000;
const ring = new THREE.Mesh(selectionRingGeometry, selectionRingMaterial);
ring.renderOrder = 1000;
marker.add(dot, ring);
boneSelection.add(marker);
return marker;
});
}

// Image-based environment light: soft bounced light that gives the matte
// figure materials realistic shading gradients instead of flat CG plastic.
const pmrem = new THREE.PMREMGenerator(renderer);
Expand Down Expand Up @@ -911,6 +956,23 @@ export function createViewer(
// geometry; a segmented skin can have a different lowest point as limbs
// rotate. Reconcile the actual skinned surface after every animation layer.
if (character && solvedInfo && isFloorBound(solvedInfo)) character.reconcileFloor();
// Follow the visible rig (including mocap and its final floor correction)
// when available; the congruent procedural driver is the fallback.
for (let i = 0; i < selectedBoneIds.length; i++) {
const boneId = selectedBoneIds[i]!;
const marker = selectionMarkers[i]!;
const position =
character?.getJointWorldPosition(boneId) ??
mannequin.bones.get(boneId)?.getWorldPosition(new THREE.Vector3()) ??
null;
marker.visible = position !== null;
if (position) {
marker.position.copy(position);
// The torus is a screen-facing selection ring; the centre dot remains
// spherical, so copying the camera frame works for both children.
marker.quaternion.copy(camera.quaternion);
}
}
frameDt = 0;
if (easeCamera) {
controls.target.lerp(desiredTarget, 0.07);
Expand Down Expand Up @@ -1242,6 +1304,12 @@ export function createViewer(
getVisibleBounds() {
return character?.getBounds() ?? new THREE.Box3().setFromObject(mannequin.root);
},
selectBones(boneIds) {
selectedBoneIds = [...new Set(boneIds)].filter((id) =>
mannequin.bones.has(id),
);
rebuildBoneSelection();
},
getMannequin() {
return mannequin;
},
Expand All @@ -1267,6 +1335,10 @@ export function createViewer(
clipLayer?.dispose();
character?.dispose();
floorGuide?.dispose();
selectionDotGeometry.dispose();
selectionRingGeometry.dispose();
selectionDotMaterial.dispose();
selectionRingMaterial.dispose();
renderer.dispose();
},
};
Expand Down
9 changes: 9 additions & 0 deletions playground/play.html
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@
<section class="viewer-pane">
<canvas id="canvas"></canvas>
<div class="viewer-veil" aria-hidden="true"></div>
<div
id="joint-selection"
class="joint-selection"
aria-live="polite"
hidden
>
<span>selected</span>
<strong id="joint-selection-name"></strong>
</div>

<div class="hud">
<div id="phase" class="phase"></div>
Expand Down
109 changes: 109 additions & 0 deletions playground/src/direct-manipulation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
ACTION_NAMES,
JOINT_NAMES,
expandJoint,
romFor,
} from "posecode-parser";

const JOINT_SET = new Set<string>(JOINT_NAMES);
const ACTION_SET = new Set<string>(ACTION_NAMES);

/**
* A directly-manipulable `<joint>: <action> <angle>` source line.
* Positions are absolute CodeMirror document offsets and use half-open ranges.
*/
export interface AngleTarget {
joint: string;
action: string;
degrees: number;
jointFrom: number;
jointTo: number;
angleFrom: number;
angleTo: number;
}

export interface AngleRange {
min: number;
max: number;
}

// Keep this deliberately stricter than syntax highlighting. Only complete,
// parser-valid joint target lines become controls; comments, turn/travel
// numbers, and half-written source remain ordinary editable text.
const JOINT_TARGET =
/^(\s*)([A-Za-z][\w-]*)(\s*:\s*)([A-Za-z][\w-]*)(\s+)(-?(?:\d+(?:\.\d*)?|\.\d+))(?=\s*(?:(?:#|\/\/).*)?$)/;

/** Locate every source angle that can safely become an inline control. */
export function findAngleTargets(source: string): AngleTarget[] {
const targets: AngleTarget[] = [];
let lineFrom = 0;

for (const line of source.split(/\n/)) {
const match = JOINT_TARGET.exec(line.replace(/\r$/, ""));
if (match && JOINT_SET.has(match[2]!) && ACTION_SET.has(match[4]!)) {
const joint = match[2]!;
const action = match[4]!;
// Unsupported joint/action pairs stay plain text so the parser error
// remains the primary interaction rather than presenting a bogus range.
if (angleRangeFor(joint, action)) {
const jointFrom = lineFrom + match[1]!.length;
const angleFrom =
jointFrom +
match[2]!.length +
match[3]!.length +
match[4]!.length +
match[5]!.length;
const angleText = match[6]!;
targets.push({
joint,
action,
degrees: Number(angleText),
jointFrom,
jointTo: jointFrom + joint.length,
angleFrom,
angleTo: angleFrom + angleText.length,
});
}
}
// split() removes the newline, so account for it between every pair.
lineFrom += line.length + 1;
}

return targets;
}

/** Find the target whose joint or angle contains a document position. */
export function angleTargetAt(
source: string,
position: number,
part: "joint" | "angle",
): AngleTarget | null {
for (const target of findAngleTargets(source)) {
const from = part === "joint" ? target.jointFrom : target.angleFrom;
const to = part === "joint" ? target.jointTo : target.angleTo;
if (position >= from && position < to) return target;
}
return null;
}

/**
* Return the ROM intersection for all bones represented by a DSL joint name.
* Groups therefore get one honest range that is valid for every selected bone.
*/
export function angleRangeFor(joint: string, action: string): AngleRange | null {
const bones = expandJoint(joint);
const limits = bones
.map((bone) => romFor(bone, action))
.filter((limit): limit is AngleRange => limit !== null);
if (limits.length === 0 || limits.length !== bones.length) return null;

const min = Math.max(...limits.map((limit) => limit.min));
const max = Math.min(...limits.map((limit) => limit.max));
return min <= max ? { min, max } : null;
}

/** Clamp and format a spinner value without accumulating float noise. */
export function normalizeAngle(value: number, range: AngleRange): string {
const clamped = Math.min(range.max, Math.max(range.min, value));
return String(Math.round(clamped * 10) / 10);
}
Loading