cozyclay 3.0.1 → 3.0.3
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.
- package/CHANGELOG.md +57 -0
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/bin/agent/pi-tools.mjs +12 -1
- package/bin/agent/studio-context-budget.mjs +8 -2
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +1 -1
- package/dist/app/index.html +4 -4
- package/dist/assets/analytics-BibyE5ar.js +6 -0
- package/dist/assets/app-DhRJ-PBz.js +5088 -0
- package/dist/assets/{compile-worker-BxKXh07s.js → compile-worker-DqiA4AaN.js} +3 -3
- package/dist/assets/{first-shot-handoff-DNO8kv_N.js → first-shot-handoff-Qx7SKl38.js} +1 -1
- package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
- package/dist/assets/{landing-yfZaJkWc.js → landing-DYg87MDY.js} +1 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +20 -0
- package/dist/assets/{workflow-D78eiKTa.js → workflow-BSA22Ofr.js} +1 -1
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +3 -3
- package/dist/workflow/index.html +3 -3
- package/package.json +1 -1
- package/src/agent-turn-mark.js +33 -0
- package/src/bus-task-scopes.js +69 -0
- package/src/bus-task-targets.js +48 -0
- package/src/bus-tasks.js +175 -0
- package/src/command-bus.js +21 -47
- package/src/command-history.js +36 -0
- package/src/commands/cast.js +10 -3
- package/src/commands/elements/character.js +2 -1
- package/src/commands/elements/motion.js +7 -1
- package/src/commands/elements/scene.js +3 -0
- package/src/commands/elements/stage.js +2 -1
- package/src/commands/elements/take-read.js +31 -0
- package/src/commands/elements.js +16 -2
- package/src/commands/motion.js +2 -1
- package/src/commands/motion3.js +13 -5
- package/src/commands/objects.js +2 -2
- package/src/commands/shared.js +9 -0
- package/src/motion3/compiler/anchors.js +89 -5
- package/src/motion3/compiler/compile.js +25 -4
- package/src/motion3/compiler/grip.js +16 -4
- package/src/motion3/compiler/socket-grip.js +131 -0
- package/src/motion3/compiler/validate-parts.js +7 -2
- package/src/motion3/compiler/vocab.js +6 -0
- package/src/motion3/docs.js +1 -1
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/free-motion.js +12 -4
- package/src/motion3/object-keys.js +36 -5
- package/src/motion3/object-pose.js +38 -5
- package/src/motion3/pipeline.js +2 -0
- package/src/motion3/qa/spec-checks.js +12 -1
- package/src/motion3/scene.js +4 -3
- package/src/motion3/schema/fields.js +1 -1
- package/src/motion3/schema/topics.js +4 -3
- package/src/motion3/schema/types.js +3 -2
- package/src/motion3/skills/basic.js +68 -14
- package/src/motion3/skills/index.js +4 -2
- package/src/motion3/skills/info.js +5 -5
- package/src/motion3/skills/reach-human.js +139 -0
- package/src/motion3/skills/stance.js +92 -0
- package/src/motion3/sockets.js +121 -0
- package/src/motion3/take-edit.js +1 -1
- package/src/motion3/take.js +6 -4
- package/src/motion3/views.js +28 -5
- package/src/object-catalog.jsx +3 -1
- package/src/object-grasp.js +38 -0
- package/src/object-look.js +2 -0
- package/src/object-travel.js +6 -3
- package/src/panels/ObjectLookPanel.jsx +24 -3
- package/src/props.jsx +5 -5
- package/src/scene-objects.js +19 -11
- package/src/shell/details-selection.js +2 -1
- package/src/studio-agent-protocol.js +5 -6
- package/src/studio-app-binding.js +3 -3
- package/src/studio-elements.js +3 -1
- package/src/studio-error-codes.js +1 -1
- package/src/studio-motion-visual.js +4 -4
- package/src/text/fonts/helvetiker-LICENSE.txt +13 -0
- package/src/text/fonts/helvetiker-bold.typeface.json +1 -0
- package/src/text/text-object.jsx +106 -0
- package/src/text/text-record.js +122 -0
- package/tools/run-tests.mjs +1 -1
- package/dist/assets/analytics-BWCqdgWA.js +0 -1
- package/dist/assets/app-DfTCxaKk.js +0 -5087
- package/dist/assets/scene-asset-sync-U3kS2rvK.js +0 -20
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createCharacterEntry } from '../../scenes.js';
|
|
2
2
|
import { STUDIO_ELEMENTS } from '../../studio-elements.js';
|
|
3
3
|
import { registerElementKind } from '../elements.js';
|
|
4
|
+
import { readCastTakes } from './take-read.js';
|
|
4
5
|
|
|
5
6
|
const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('character.')).map(row => {
|
|
6
7
|
const { min, max, gizmo, ...element } = row;
|
|
@@ -9,5 +10,5 @@ const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('character.')
|
|
|
9
10
|
if (row.path === 'character.pose') return { ...element, readback: item => ({ text: item?.pose?.id ?? null }) };
|
|
10
11
|
return element;
|
|
11
12
|
});
|
|
12
|
-
registerElementKind('character', { collection: true, documentKey: 'characters', elements, normalize: createCharacterEntry });
|
|
13
|
+
registerElementKind('character', { collection: true, documentKey: 'characters', elements, read: readCastTakes, normalize: createCharacterEntry });
|
|
13
14
|
registerElementKind('poseLibrary', { collection: true, documentKey: 'customPoses', elements: [], normalize: value => value });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registerElementKind } from '../elements.js';
|
|
2
|
+
import { readTake } from './take-read.js';
|
|
2
3
|
|
|
3
4
|
const elements = [
|
|
4
5
|
{ path: 'motion.take.prompt', type: 'string' },
|
|
@@ -6,4 +7,9 @@ const elements = [
|
|
|
6
7
|
{ path: 'motion.takeRecipe', type: 'object', readOnly: true },
|
|
7
8
|
{ path: 'motion.takeVersions', type: 'array', readOnly: true },
|
|
8
9
|
];
|
|
9
|
-
|
|
10
|
+
// The full take is the take itself until an edit splits them: a read answers its source once.
|
|
11
|
+
const readMotion = (rows, named) => rows.map(row => {
|
|
12
|
+
const same = Boolean(row.fullTake?.resourceId) && row.fullTake.resourceId === row.take?.resourceId;
|
|
13
|
+
return { ...row, take: readTake(row.take, named(row.id)), fullTake: readTake(row.fullTake, !same && named(row.id)) };
|
|
14
|
+
});
|
|
15
|
+
registerElementKind('motion', { collection: true, elements, read: readMotion, normalize: row => ({ ...row, take: row.take && { ...row.take } }) });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registerElementKind } from '../elements.js';
|
|
2
|
+
import { readCastTakes } from './take-read.js';
|
|
2
3
|
|
|
3
4
|
// Array position is the authoritative order. The owner refreshes this persisted
|
|
4
5
|
// field after every move, so generic set/read and the project file agree.
|
|
@@ -8,6 +9,8 @@ registerElementKind('scene', {
|
|
|
8
9
|
{ path: 'scene.name', type: 'string' },
|
|
9
10
|
{ path: 'scene.order', type: 'number' },
|
|
10
11
|
],
|
|
12
|
+
// a saved scene keeps its cast's takes: a read answers their spec ids (take-read.js)
|
|
13
|
+
read: (scenes, named) => scenes.map(scene => (scene?.stage?.characters ? { ...scene, stage: { ...scene.stage, characters: readCastTakes(scene.stage.characters, named) } } : scene)),
|
|
11
14
|
normalize: scene => ({ ...scene, name: scene.name.trim(), order: Math.max(0, Math.round(scene.order)) }),
|
|
12
15
|
});
|
|
13
16
|
registerElementKind('project', {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createSceneStage } from '../../scenes.js';
|
|
2
2
|
import { STUDIO_ELEMENTS } from '../../studio-elements.js';
|
|
3
3
|
import { registerElementKind } from '../elements.js';
|
|
4
|
+
import { readCastTakes } from './take-read.js';
|
|
4
5
|
|
|
5
6
|
const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('stage.'));
|
|
6
7
|
export const STAGE_FIELDS = [...new Set(elements.map(element => (element.documentPath ?? element.path.slice(6)).split('.')[0]))];
|
|
@@ -8,4 +9,4 @@ export function normalizeStage(source) {
|
|
|
8
9
|
const normalized = createSceneStage(source);
|
|
9
10
|
return Object.fromEntries(STAGE_FIELDS.map(key => [key, normalized[key]]));
|
|
10
11
|
}
|
|
11
|
-
registerElementKind('stage', { elements, normalize: normalizeStage });
|
|
12
|
+
registerElementKind('stage', { elements, read: (stage, named) => (stage?.characters ? { ...stage, characters: readCastTakes(stage.characters, named) } : stage), normalize: normalizeStage });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { specIdOf } from '../../motion3/spec-store.js';
|
|
2
|
+
|
|
3
|
+
// A take's source (its spec, placement and the scene boxes it was compiled against) runs to
|
|
4
|
+
// ~110k characters a character, and a document read carried it in full wherever a take sits:
|
|
5
|
+
// motion (as take and fullTake), the cast and stage (motionTake) and every saved scene. One read
|
|
6
|
+
// of motion alone answered 460k characters, ~300k tokens a model call for the rest of the turn.
|
|
7
|
+
// A read answers the take's specId instead (motion.compile { base: specId } recompiles it); a
|
|
8
|
+
// character named in ids also gets its spec and placement, never the scene boxes.
|
|
9
|
+
const LEFT_OUT = 'left out of this read: name this character in ids for its spec and placement, or compile { base: specId }';
|
|
10
|
+
const leftOutScene = scene => `left out (the ${Object.keys(scene?.objects ?? {}).length} scene objects it was compiled against)`;
|
|
11
|
+
|
|
12
|
+
/** A take source ({ schema, rig, spec, placement, scene, ... }) as a read answers it: { specId, ... }. */
|
|
13
|
+
function readSource(source, named) {
|
|
14
|
+
const specId = specIdOf(source.spec);
|
|
15
|
+
if (!named) return { specId, frames: source.spec?.frames, ...(source.takeId ? { takeId: source.takeId } : {}), ...(source.edit ? { edit: source.edit } : {}), spec: LEFT_OUT };
|
|
16
|
+
const { scene, ...kept } = source;
|
|
17
|
+
return { specId, ...kept, scene: leftOutScene(scene) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A motion layer's take descriptor, its source read as above. */
|
|
21
|
+
export function readTake(take, named) {
|
|
22
|
+
if (!take?.source) return take;
|
|
23
|
+
const { source, ...descriptor } = take, specId = specIdOf(source.spec);
|
|
24
|
+
if (!named) return { ...descriptor, specId, source: LEFT_OUT };
|
|
25
|
+
const { scene, ...kept } = source;
|
|
26
|
+
return { ...descriptor, specId, source: { ...kept, scene: leftOutScene(scene) } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cast rows with their saved takes (motionTake) read as above. */
|
|
30
|
+
export const readCastTakes = (characters, named) => (Array.isArray(characters)
|
|
31
|
+
? characters.map(row => (row?.motionTake?.spec ? { ...row, motionTake: readSource(row.motionTake, named(row.id)) } : row)) : characters);
|
package/src/commands/elements.js
CHANGED
|
@@ -42,15 +42,29 @@ export function elementSetSchema(kind) {
|
|
|
42
42
|
export function elementTarget(kind, value, id, sceneId) {
|
|
43
43
|
return kinds.get(projectionKind(kind)).collection ? value.find(row => row.id === id) : id === sceneId ? value : undefined;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
// A kind's `read` shapes what a document read answers (motion leaves out a take's bulky source
|
|
46
|
+
// unless its row id was named): `named` are the ids the caller asked for by name. A read answers
|
|
47
|
+
// at most DOCUMENT_READ_CHARS of kinds, the smallest first; the rest are named in `omitted` with
|
|
48
|
+
// their size, to select alone or by ids. One kind alone answers whole (ids narrow it).
|
|
49
|
+
const DOCUMENT_READ_CHARS = 90_000;
|
|
50
|
+
export function readElementDocument(projection, { ids, select, named = ids } = {}, sceneId) {
|
|
46
51
|
const entries = Object.entries(projection).flatMap(([key, value]) => {
|
|
47
52
|
const kind = projectionKind(key);
|
|
48
53
|
if (select && !select.includes(key) && !select.includes(kind)) return [];
|
|
49
54
|
if (!ids || ids.includes(key) || ids.includes(kind) || ids.includes(sceneId)) return [[key, value]];
|
|
50
55
|
const selected = kinds.get(kind).collection ? value.filter(row => ids.includes(row.id)) : [];
|
|
51
56
|
return selected.length ? [[key, selected]] : [];
|
|
57
|
+
}).map(([key, value]) => {
|
|
58
|
+
const read = kinds.get(projectionKind(key))?.read;
|
|
59
|
+
return [key, read ? read(value, id => Boolean(named?.includes(id))) : value];
|
|
52
60
|
});
|
|
53
|
-
|
|
61
|
+
const sized = entries.map(([key, value]) => { const schema = elementSetSchema(projectionKind(key)); return { key, value, schema, chars: JSON.stringify([value, schema]).length }; });
|
|
62
|
+
let room = DOCUMENT_READ_CHARS;
|
|
63
|
+
const fits = new Set(sized.length < 2 ? sized.map(row => row.key) : [...sized].sort((a, b) => a.chars - b.chars).filter(row => (room -= row.chars) >= 0).map(row => row.key));
|
|
64
|
+
const kept = sized.filter(row => fits.has(row.key)), omitted = sized.filter(row => !fits.has(row.key));
|
|
65
|
+
return { document: structuredClone(Object.fromEntries(kept.map(row => [row.key, row.value]))), schema: Object.fromEntries(kept.map(row => [row.key, row.schema])),
|
|
66
|
+
...(omitted.length ? { omitted: Object.fromEntries(omitted.map(row => [row.key, row.chars])),
|
|
67
|
+
note: `Left out to keep this read under ${DOCUMENT_READ_CHARS} characters (sizes in omitted): select a kind alone, name ids, or page objects with scope "entities".` } : {}) };
|
|
54
68
|
}
|
|
55
69
|
export function readElement(document, path) {
|
|
56
70
|
const element = kinds.get(path.slice(0, path.indexOf('.'))).elements.find(row => row.path === path);
|
package/src/commands/motion.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Motion and IK commands share one per-character authored-intent owner.
|
|
2
2
|
import { STUDIO_IK_CHAIN_TRACKS, studioActionDeclaration } from "../studio-actions.js";
|
|
3
|
-
import { fail, characterOf } from "./shared.js";
|
|
3
|
+
import { fail, characterOf, checkPoseBones } from "./shared.js";
|
|
4
4
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
5
5
|
import { registerTiming, timingDeclarations } from './motion-timing.js';
|
|
6
6
|
import './elements/motion.js';
|
|
@@ -41,6 +41,7 @@ export function register(registry, ports) {
|
|
|
41
41
|
for (const declaration of tools) registry.register({ ...declaration, available: mounted, run(args) {
|
|
42
42
|
characterOf(ports, args.characterId);
|
|
43
43
|
if (args.frame >= ports.state().frameCount) fail('INVALID_RANGE', 'IK frame is outside the timeline.');
|
|
44
|
+
checkPoseBones(args.pose.bones);
|
|
44
45
|
for (const angles of Object.values(args.pose.bones)) if (!Array.isArray(angles) || angles.length !== 3 || !angles.every(Number.isFinite)) fail('INVALID_ARGUMENT', 'Pose bones require three finite rotation angles.');
|
|
45
46
|
owner().keyPose(args.characterId, args.frame, args.pose);
|
|
46
47
|
return { affectedIds: [args.characterId], summary: declaration.label };
|
package/src/commands/motion3.js
CHANGED
|
@@ -48,7 +48,7 @@ const declarations = Object.freeze([
|
|
|
48
48
|
input: input({ topic: { type: "string", enum: DESCRIBE_TOPICS }, name: id,
|
|
49
49
|
topics: list({ oneOf: [{ type: "string", enum: DESCRIBE_TOPICS }, input({ topic: { type: "string", enum: DESCRIBE_TOPICS }, name: id }, ["topic"])] }, 8) }, []) },
|
|
50
50
|
{ id: "motion.report", label: "Read motion report", kind: "job", domain: "motion", timeoutMs: 10000, readOnly: true, description: HELP.report,
|
|
51
|
-
input: input({ specId, part: { type: "string", enum: ["groups", "timeline", "probes"], default: "groups" }, from: { type: "integer", minimum: 0, default: 0 } }, [
|
|
51
|
+
input: input({ specId, characterId: id, part: { type: "string", enum: ["groups", "timeline", "probes"], default: "groups" }, from: { type: "integer", minimum: 0, default: 0 } }, []) },
|
|
52
52
|
{ id: "motion.keyObject", label: "Key held object", kind: "job", domain: "motion", timeoutMs: 120000, description: HELP.keyObject,
|
|
53
53
|
input: input({ characterId: id, objectId: id, frame: { type: "integer", minimum: 0 }, pos: numbers(3), rot: numbers(4), aim: numbers(3), roll: { type: "number" },
|
|
54
54
|
angle: { type: "number" }, remove: { type: "boolean", default: false } }, ["characterId", "objectId", "frame"]) },
|
|
@@ -141,7 +141,9 @@ export function register(registry, ports) {
|
|
|
141
141
|
const character = characterOf(ports, characterId);
|
|
142
142
|
if ((spec === undefined) === (base === undefined)) fail("INVALID_ARGUMENT", "Send exactly one of spec or base (the specId of a compiled spec, with patch).");
|
|
143
143
|
if (patch !== undefined && base === undefined) fail("INVALID_ARGUMENT", "patch applies to base, the specId of a compiled spec; send base with it, or the whole spec.");
|
|
144
|
-
|
|
144
|
+
// a base the store forgot (a reload) may still be the installed take's spec: a document read names it by specId
|
|
145
|
+
const installedSpec = () => { const take = owner().motionFor(characterId); return take?.spec && specIdOf(take.spec) === base ? structuredClone(take.spec) : null; };
|
|
146
|
+
const patched = [], source = spec ?? asArgument(() => applyPatch(store.get(base) ?? installedSpec() ?? unknownSpec("base", base), patch ?? [], patched));
|
|
145
147
|
// the take is always baked: the user watches every draft, installed or not
|
|
146
148
|
return after(compile(character, source, { probes, repair }, {}), r => {
|
|
147
149
|
const { qa, take } = r, notes = [...patched, ...(r.notes ?? [])];
|
|
@@ -196,9 +198,15 @@ export function register(registry, ports) {
|
|
|
196
198
|
}
|
|
197
199
|
return { affectedIds: [], summary: `Described ${topic}${name ? ` ${name}` : ""}.`, output: asArgument(() => describe(topic, name)) };
|
|
198
200
|
} });
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
// a characterId reads the installed take's report (session 2ad2760a asked for it that way and was refused): the stored
|
|
202
|
+
// one when this session compiled that spec, else the take's QA run now
|
|
203
|
+
registry.register({ ...declarations[4], available: always, run({ specId: wanted, characterId, part = "groups", from = 0 }) {
|
|
204
|
+
const answer = (parts, id) => { const page = reportPage(parts, id, part, from); return { affectedIds: [], summary: `${page.items.length} ${part} from ${from}.`, output: page }; };
|
|
205
|
+
if (wanted) return answer(store.parts(wanted) ?? unknownSpec("specId", wanted, "compile it again"), wanted);
|
|
206
|
+
if (!characterId) fail("INVALID_ARGUMENT", "motion.report needs specId (from a motion.compile or motion.qa answer) or characterId (its installed take).");
|
|
207
|
+
const take = installed(characterId), id = specIdOf(take.spec), kept = store.parts(id);
|
|
208
|
+
if (kept) return answer(kept, id);
|
|
209
|
+
return after(compile(characterOf(ports, characterId), take.spec), r => { const out = report(r, {}); return answer(store.parts(out.specId), out.specId); });
|
|
202
210
|
} });
|
|
203
211
|
// motion.keyObject (C14): one key of a held object (or a hinge angle) in the installed take's spec, compiled
|
|
204
212
|
// and installed whatever QA says (a user edit), as one undo entry
|
package/src/commands/objects.js
CHANGED
|
@@ -24,7 +24,7 @@ const PATCH_HINTS = { scale: 'use scaleX, scaleY, scaleZ', rotY: 'use rot for ya
|
|
|
24
24
|
const mutation = (id, label, input) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'objects', input });
|
|
25
25
|
const semantic = [
|
|
26
26
|
mutation('object.set', 'Set object fields', setInput),
|
|
27
|
-
mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id, first: { type: 'boolean' } }, ['kind'])),
|
|
27
|
+
mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id, first: { type: 'boolean' }, text: setInput.properties.set.properties.text }, ['kind'])),
|
|
28
28
|
// A clay prop from a recipe of soft parts (docs/sculpt-from-image.md). With `id` it re-sculpts that
|
|
29
29
|
// sculpt in place (one undo); without, it creates one. The recipe is checked by sculpt-recipe.js.
|
|
30
30
|
{ ...mutation('object.sculpt', 'Sculpt a clay prop from a recipe', input({ recipe: { type: 'object', properties: {}, additionalProperties: true }, edits: { type: 'array', minItems: 1, maxItems: 48, items: { type: 'object', properties: {}, additionalProperties: true } }, id, name: { type: 'string', maxLength: 240 }, placement, parent: id }, [])),
|
|
@@ -75,7 +75,7 @@ export function register(registry, ports) {
|
|
|
75
75
|
'object.add': args => {
|
|
76
76
|
const before = owned().read();
|
|
77
77
|
if (args.parent !== undefined) objectOf(args.parent);
|
|
78
|
-
const object = createSceneObject(args.kind, before, args.placement);
|
|
78
|
+
const object = createSceneObject(args.kind, before, args.placement, args.text);
|
|
79
79
|
if (!object) fail('INVALID_ARGUMENT', `Unknown object kind: ${args.kind}`);
|
|
80
80
|
const placed = updateSceneObject([object], object.id, { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) })[0];
|
|
81
81
|
// `first`: the Outliner lists Props in record order, so a UI-made object
|
package/src/commands/shared.js
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
// object: its members are read at call time, so a run always reaches the
|
|
3
3
|
// latest handlers and `ports.state()` the synchronously published document.
|
|
4
4
|
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
5
|
+
import { POSE_BONES } from "../poses.js";
|
|
5
6
|
|
|
6
7
|
export const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
7
8
|
|
|
9
|
+
// A pose bone outside POSE_BONES is dropped by applyPose, so the pose falls back to the
|
|
10
|
+
// rest (T) pose for that joint. Refuse it and name the joints a pose can set (#773).
|
|
11
|
+
const POSE_BONE_IDS = POSE_BONES.map(entry => entry.id);
|
|
12
|
+
export function checkPoseBones(bones) {
|
|
13
|
+
const unknown = Object.keys(bones ?? {}).filter(name => !POSE_BONE_IDS.includes(name));
|
|
14
|
+
if (unknown.length) fail("INVALID_ARGUMENT", `Unknown pose bone ${unknown.join(", ")}. Use one of: ${POSE_BONE_IDS.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/** Ids of the rows added, changed or removed between two renders of a list. */
|
|
9
18
|
export const changedIds = (before, after) => [...new Set([
|
|
10
19
|
...after.filter(row => !before.includes(row)).map(row => row.id),
|
|
@@ -9,20 +9,26 @@
|
|
|
9
9
|
// { kind: "object", object: "Car_Door_L", local: [x,y,z] } point fixed to a scene object or group where it is at
|
|
10
10
|
// that frame (hinged, carried, following; object-pose.js objectPoseAt), in its own axes
|
|
11
11
|
// { kind: "landmark", name: "mouth" } measured face landmark on the Head bone
|
|
12
|
-
// { kind: "prop", prop: "cig", point: "tip", offset: [x,y,z] } a point on a prop held by the other hand
|
|
12
|
+
// { kind: "prop", prop: "cig", point: "tip", offset: [x,y,z] } a point on a prop held by the other hand; while
|
|
13
|
+
// that hand does not hold the object it carries, the point stays on that object (propOnCarry)
|
|
13
14
|
// { kind: "person", person: "char-b", bone: "LeftArm", offset: [x,y,z] } world offset from another character's joint
|
|
14
15
|
// where its installed take has it at that frame (people.js)
|
|
15
16
|
// spec.props = { name: { hand: "r", origin: ["RightHandIndex2", "RightHandMiddle2"],
|
|
16
17
|
// points: { name: [d, l, n] } } } offsets along the palm axes (m)
|
|
17
|
-
import { add, sub, scale, len, apply, ry } from "./math.js";
|
|
18
|
+
import { add, sub, scale, len, dot, norm, apply, ry, T } from "./math.js";
|
|
18
19
|
import { bonePoint } from "./skeleton.js";
|
|
19
20
|
import { palmAxes } from "./limbs.js";
|
|
20
21
|
import { SIDES } from "./pose.js";
|
|
21
22
|
import { HAND_CLEAR, keepOut } from "../body.js";
|
|
22
23
|
import { groupsOf } from "../scene.js";
|
|
23
24
|
import { personJoint, peopleList } from "../people.js";
|
|
25
|
+
import { isCarried } from "../carried.js";
|
|
26
|
+
import { socketsOf, socketNamed, socketWorld, socketDistance, faceFrom, rimPoint } from "../sockets.js";
|
|
27
|
+
import { partsOf } from "../object-pose.js";
|
|
28
|
+
import { GRASP_POINTS } from "./vocab.js";
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
/** Shoulder to wrist with the arm straight (rest bone lengths), m. */
|
|
31
|
+
export function armReach(sk, k) {
|
|
26
32
|
const side = SIDES[k], P = sk.restWorldPos, I = sk.index;
|
|
27
33
|
return len(sub(P[I[`${side}ForeArm`]], P[I[`${side}Arm`]])) + len(sub(P[I[`${side}Hand`]], P[I[`${side}ForeArm`]]));
|
|
28
34
|
}
|
|
@@ -81,10 +87,14 @@ function anchorPoint(ctx, a, name, s, diag, c, side) {
|
|
|
81
87
|
for (let t = a.t ?? 0.45; t >= (a.tMin ?? 0.15); t -= 0.01) if (len(sub(at(t), sh)) <= r) return at(t);
|
|
82
88
|
return at(a.tMin ?? 0.15);
|
|
83
89
|
}
|
|
84
|
-
case "object": {
|
|
90
|
+
case "object": {
|
|
91
|
+
if (a.grasp && a.local === undefined) return anchorSocket(ctx, name, s, diag, c, side).p;
|
|
92
|
+
const o = objectFrame(ctx, a, name, { s, diag, c }, side);
|
|
93
|
+
return add(o.pos, apply(o.R, a.local ?? [0, 0, 0]));
|
|
94
|
+
}
|
|
85
95
|
case "landmark": return bonePoint(s, I.Head, sk.landmarks[a.name] ?? (() => { throw new Error(`MOTION_SPEC_INVALID: unknown landmark "${a.name}"`); })());
|
|
86
96
|
case "prop": {
|
|
87
|
-
const p = diag.props?.[a.prop]?.[a.point];
|
|
97
|
+
const p = propOnCarry(ctx, a, name, diag?.frame) ?? diag.props?.[a.prop]?.[a.point];
|
|
88
98
|
if (!p) throw new Error(`MOTION_SPEC_INVALID: anchor "${name}" needs prop ${a.prop}.${a.point}, which is held by an arm solved later`);
|
|
89
99
|
return add(p, a.offset ?? [0, 0, 0]);
|
|
90
100
|
}
|
|
@@ -97,6 +107,80 @@ function anchorPoint(ctx, a, name, s, diag, c, side) {
|
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A point of a prop on a hand that carries an object, at a frame that hand does not hold it: where the point sits on
|
|
112
|
+
* the object at the grab (or the release) nearest in time, carried with the object (at rest before the grab, let go
|
|
113
|
+
* after the release). In hand the point rides the palm; before the grab the palm still turns into its grip (and after
|
|
114
|
+
* the release out of it), and a point held off it swept through the other arm's reach: session 02263581's barbell put
|
|
115
|
+
* the left hand on a point 55 cm off the right palm, and the left elbow jumped 28 cm in a frame at the grab and the
|
|
116
|
+
* release. Read off the object, the point is where the left hand takes the bar, and meets the palm's at the grab. null
|
|
117
|
+
* when no carry of that hand applies (the palm's point is used).
|
|
118
|
+
*/
|
|
119
|
+
function propOnCarry(ctx, a, name, f) {
|
|
120
|
+
const hand = ctx.spec.props?.[a.prop]?.hand;
|
|
121
|
+
if (!hand || f === undefined || !ctx.solvedAt || !ctx.carrySpan) return null;
|
|
122
|
+
let pick = null;
|
|
123
|
+
for (const [id, o] of Object.entries(ctx.spec.objects ?? {})) {
|
|
124
|
+
if (!isCarried(o) || ctx.spec.props?.[o.prop]?.hand !== hand) continue;
|
|
125
|
+
const [grab, release] = ctx.carrySpan(o);
|
|
126
|
+
if (f >= grab && f <= release) return null;
|
|
127
|
+
const at = f < grab ? grab : release;
|
|
128
|
+
if (!pick || Math.abs(f - at) < Math.abs(f - pick.at)) pick = { id, at };
|
|
129
|
+
}
|
|
130
|
+
if (!pick) return null;
|
|
131
|
+
const there = nested(ctx, name, pick.id, pick.at, () => ctx.solvedAt(pick.at).diag.props?.[a.prop]?.[a.point]);
|
|
132
|
+
const was = ctx.objectPose(pick.id, pick.at), now = ctx.objectPose(pick.id, f);
|
|
133
|
+
return there && was && now ? add(now.pos, apply(now.R, apply(T(was.R), sub(there, was.pos)))) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The socket (../sockets.js) an object anchor with `grasp` takes the hand by, in world space on its object's pose that
|
|
138
|
+
* frame: { p, axis?, from?, thumb? }, p the anchor's point (its `local` when it has one). Its `socket`, else the one
|
|
139
|
+
* nearest its `local` point or, without one, nearest the shoulder of the hand: picked once per compile (ctx.sockets),
|
|
140
|
+
* so the hand does not hop between sockets. An object with no socket gives the bare point: the palm faces it.
|
|
141
|
+
*/
|
|
142
|
+
export function anchorSocket(ctx, name, s, diag, c, side) {
|
|
143
|
+
const a = ctx.spec.anchors[name], now = { s, diag, c }, poseOf = id => objectFrame(ctx, { ...a, object: id }, name, now, side);
|
|
144
|
+
const local = a.local && (o => add(o.pos, apply(o.R, a.local)))(poseOf(a.object));
|
|
145
|
+
ctx.sockets ??= new Map();
|
|
146
|
+
if (!ctx.sockets.has(name)) {
|
|
147
|
+
const all = socketsOf(ctx.scene, a.object), I = ctx.sk.index, near = local ?? s.P[I[side ? `${SIDES[side]}Arm` : "Hips"]];
|
|
148
|
+
const pick = a.socket ? socketNamed(ctx.scene, a.object, a.socket) : all.map(k => ({ k, d: socketDistance(socketWorld(k, poseOf(k.owner)), near) })).sort((x, y) => x.d - y.d)[0]?.k;
|
|
149
|
+
ctx.sockets.set(name, pick ?? null);
|
|
150
|
+
}
|
|
151
|
+
const sock = ctx.sockets.get(name);
|
|
152
|
+
let w = sock ? socketWorld(sock, poseOf(sock.owner)) : { p: local ?? poseOf(a.object).pos };
|
|
153
|
+
if (sock?.rim) w = rimHold(ctx, name, side, sock, poseOf(sock.owner), w, local ?? s.P[ctx.sk.index[side ? `${SIDES[side]}Arm` : "Hips"]]);
|
|
154
|
+
else if (local) w.p = local;
|
|
155
|
+
// a thick body (a cup) holds its axis further off the finger middles than the grasp's point does (vocab.js GRASP_POINTS,
|
|
156
|
+
// 2 cm on a power grip): the point moves out from the axis toward the hand's shoulder by the difference, so the fingers
|
|
157
|
+
// close on the surface instead of being asked into the body (session 7f15b4ae: a 4 cm cup, fingers asked 2 cm inside)
|
|
158
|
+
else if (w.axis && w.radius > 0) {
|
|
159
|
+
const inset = Math.abs(GRASP_POINTS[a.grasp]?.[2] ?? 0), out = w.radius - inset, sh = s.P[ctx.sk.index[`${SIDES[side] ?? "Right"}Arm`]];
|
|
160
|
+
const v = sub(sh, w.p), across = sub(v, scale(w.axis, dot(v, w.axis)));
|
|
161
|
+
if (out > 1e-4 && len(across) > 1e-6) w.p = add(w.p, scale(norm(across), out));
|
|
162
|
+
}
|
|
163
|
+
// a socket with neither an axis nor a side (a box, a wall): the palm faces against the face of the body nearest the point
|
|
164
|
+
if (!w.axis && !w.from) w.from = faceFrom(partsOf(ctx.scene, a.object).map(id => ({ ...poseOf(id), half: ctx.scene.objects[id].half })), w.p);
|
|
165
|
+
return w;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A rim socket held by hand `side`: the rim point nearest q (the anchor's local point, else the shoulder) the first time
|
|
170
|
+
* it is asked, kept in the rim's own axes so the hand stays on that spot of the rim as the rim moves (a wheel in a truck).
|
|
171
|
+
*/
|
|
172
|
+
function rimHold(ctx, name, side, sock, pose, w, q) {
|
|
173
|
+
ctx.rims ??= new Map();
|
|
174
|
+
// asked with no hand (a contact check, a goal): the spot a hand already holds
|
|
175
|
+
const held = side ? null : [...ctx.rims.keys()].find(k => k.startsWith(`${name}|`) && !k.endsWith("|"));
|
|
176
|
+
const key = held ?? `${name}|${side ?? ""}`;
|
|
177
|
+
if (!ctx.rims.has(key)) {
|
|
178
|
+
const r = rimPoint(w, q), back = v => apply(T(pose.R), v);
|
|
179
|
+
ctx.rims.set(key, { at: back(sub(r.p, pose.pos)), axis: back(r.axis) });
|
|
180
|
+
}
|
|
181
|
+
return socketWorld(ctx.rims.get(key), pose);
|
|
182
|
+
}
|
|
183
|
+
|
|
100
184
|
/** Points of every prop held by hand k, in world space, written to diag.props. */
|
|
101
185
|
export function measureProps(ctx, k, s, diag) {
|
|
102
186
|
const { sk } = ctx;
|
|
@@ -23,19 +23,22 @@ import { isCarried, isFollow, hingePose, movesRecord, compose, relative } from "
|
|
|
23
23
|
import { isFree, freePoseAt } from "../free-motion.js";
|
|
24
24
|
import { groupsOf } from "../scene.js";
|
|
25
25
|
import { pose, SIDES } from "./pose.js";
|
|
26
|
+
import { graspAnchor } from "./socket-grip.js";
|
|
26
27
|
import { gripHand } from "./grip.js";
|
|
27
28
|
import { bakeChannels, channelsAt, flowOptions } from "./channels.js";
|
|
28
29
|
import { idleLayer, withIdle, busyHands } from "./idle.js";
|
|
29
|
-
import { resolveAnchor, measureProps } from "./anchors.js";
|
|
30
|
+
import { resolveAnchor, measureProps, armReach } from "./anchors.js";
|
|
30
31
|
import { expandGenerators } from "./walk.js";
|
|
31
32
|
import { steadyHead } from "./head-steady.js";
|
|
32
33
|
import { fixOptions, applyFixes } from "../fix/index.js";
|
|
33
34
|
import { fitWeight } from "../fix/self.js";
|
|
34
35
|
import { holdHook } from "../object-keys.js";
|
|
35
36
|
import { HAND_CLEAR, keepOut, wristBody } from "../body.js";
|
|
36
|
-
import { objectPoseAt, handOf, restingAt } from "../object-pose.js";
|
|
37
|
+
import { objectPoseAt, handOf, restingAt, frameAt, spanOf } from "../object-pose.js";
|
|
37
38
|
|
|
38
39
|
const smooth = x => x * x * (3 - 2 * x);
|
|
40
|
+
// how far past the straight arm a grasp fit's wrist target may end before it counts as wound up (solveFrame), m
|
|
41
|
+
const REACH_SLACK = 0.02;
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* Scene boxes by frame. A body moves with its own spec.objects entry or its nearest group's (a
|
|
@@ -146,15 +149,25 @@ export function solveFrame(ctx, c0, f, lead = true) {
|
|
|
146
149
|
if (!(c[wch] > 1e-4)) continue;
|
|
147
150
|
const full = { ...c, [wch]: 1, [`armw_${fit.side}`]: 1 };
|
|
148
151
|
for (const ch of Object.keys(full)) if (ch !== wch && new RegExp(`^w_.+_${fit.side}$`).test(ch)) full[ch] = 0;
|
|
149
|
-
|
|
152
|
+
// each pass adds what the effector still misses; an arm at full reach misses the same each pass, so on a grasp's fit
|
|
153
|
+
// the sum wound the wrist target far past the anchor (session 7f15b4ae: 0.6 m, read back as REACH 0.845 m on a cup a
|
|
154
|
+
// few cm out of reach). A grasp fit whose wrist target ends past the straight arm (REACH_SLACK) aims instead at the
|
|
155
|
+
// anchor by the hand's own wrist-to-effector offset of its best pass: the arm reaches straight for it and its
|
|
156
|
+
// residual (REACH) is what the effector misses. Other fits keep their passes as they were (their takes are pinned).
|
|
157
|
+
let corr = [0, 0, 0], best = null, last = null;
|
|
158
|
+
const grasp = graspAnchor(ctx.spec, fit.anchor), shoulder = ctx.sk.index[`${SIDES[fit.side]}Arm`], wristAt = ctx.sk.index[`${SIDES[fit.side]}Hand`];
|
|
150
159
|
for (let i = 0; i < 6; i++) {
|
|
151
160
|
full[`fit_${fit.anchor}_${fit.side}`] = corr;
|
|
152
161
|
const { s, diag } = pose(ctx.sk, full, hooksFor(ctx, full, f, true, lead, fit.side));
|
|
153
162
|
const eff = effectorPoint(ctx.sk, s, diag, fit.effector);
|
|
154
163
|
const goal = add(resolveAnchor(ctx, fit.anchor, s, diag, full, fit.side), fit.offset ?? [0, 0, 0]);
|
|
155
164
|
if (!eff) break;
|
|
156
|
-
|
|
165
|
+
const miss = sub(goal, eff), err = len(miss);
|
|
166
|
+
if (grasp && (!best || err < best.err)) best = { err, hand: sub(s.P[wristAt], eff) };
|
|
167
|
+
last = { goal, shoulder: s.P[shoulder] };
|
|
168
|
+
corr = add(corr, miss);
|
|
157
169
|
}
|
|
170
|
+
if (grasp && best && last && len(sub(add(last.goal, corr), last.shoulder)) > armReach(ctx.sk, fit.side) + REACH_SLACK) corr = best.hand;
|
|
158
171
|
c[`fit_${fit.anchor}_${fit.side}`] = corr;
|
|
159
172
|
}
|
|
160
173
|
const { s, diag } = pose(ctx.sk, c, hooksFor(ctx, c, f, false, lead));
|
|
@@ -180,10 +193,18 @@ export function compileMotion(spec, { sk, scene }, opts = {}) {
|
|
|
180
193
|
ctx.objectHand = (id, f) => handOf(out, id, f);
|
|
181
194
|
// a keyed carry's grab offset is taken where it rests at grab (object-keys.js; a fall before grab: where it landed)
|
|
182
195
|
ctx.restingAt = (id, f) => restingAt(out, id, f);
|
|
196
|
+
// a prop anchor outside its hand's carry reads the carry's grab (release) frame (anchors.js propOnCarry)
|
|
197
|
+
ctx.carrySpan = o => spanOf(out, o);
|
|
198
|
+
ctx.solvedAt = f => frameAt(out, f);
|
|
199
|
+
// a reach's grasp is planned on its first full-weight frame (socket-grip.js plannedGrasp)
|
|
200
|
+
ctx.weightAt = (ch, f) => out.baked?.[ch]?.[f];
|
|
201
|
+
ctx.frameCount = frameCount;
|
|
183
202
|
const engine = {
|
|
184
203
|
rebake(keys) {
|
|
185
204
|
out.keys = keys;
|
|
186
205
|
out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow));
|
|
206
|
+
ctx.socketTurns = new Map();
|
|
207
|
+
ctx.graspPlans = new Map();
|
|
187
208
|
steadyHead(out.baked, keys, frameCount);
|
|
188
209
|
// a held pose breathes and shifts its weight (idle.js): offsets solveFrame adds, contacts and out.baked stay as keyed
|
|
189
210
|
ctx.idle = idleLayer(out.baked, { spec, sk, frameCount });
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// forearm's neutral roll. A thin body (a cup, a can) keeps the neutral offset: its aim keys pick the
|
|
25
25
|
// roll the arm holds most easily from it, and the cylindrical grip measured worse on every bench cup.
|
|
26
26
|
// Those frames read the carried body where it rested before the grab, never a pose the hand moves.
|
|
27
|
+
// An anchor that takes a grasp (an object anchor with `grasp`) turns the hand by its socket instead (socket-grip.js).
|
|
27
28
|
import { add, sub, scale, norm, cross, dot, len, apply, ry, mul, T, axisRot } from "./math.js";
|
|
28
29
|
import { palmAxes, twoBone } from "./limbs.js";
|
|
29
30
|
import { aimArm, orient } from "./hold.js";
|
|
@@ -31,8 +32,9 @@ import { ELBOW_REST, WRIST_COMFORT, wristNeed } from "./arm-orient.js";
|
|
|
31
32
|
import { resolveAnchor } from "./anchors.js";
|
|
32
33
|
import { sdBox } from "../qa/geometry.js";
|
|
33
34
|
import { partsOf } from "../object-pose.js";
|
|
35
|
+
import { graspAnchor, socketCarry, socketHand, keepSocketTurn, ownWrist } from "./socket-grip.js";
|
|
34
36
|
|
|
35
|
-
const NEAR = 0.06, THIN = 0.045, ALIGN = 0.3, PREFER = 30, DEG = 180 / Math.PI, ITER = 2, UP = [0, 1, 0];
|
|
37
|
+
const NEAR = 0.06, UPRIGHT = 0.8, THIN = 0.045, ALIGN = 0.3, PREFER = 30, DEG = 180 / Math.PI, ITER = 2, UP = [0, 1, 0];
|
|
36
38
|
const cols = (a, b, c) => [[a[0], b[0], c[0]], [a[1], b[1], c[1]], [a[2], b[2], c[2]]];
|
|
37
39
|
const flat = (v, n) => sub(v, scale(n, dot(v, n)));
|
|
38
40
|
|
|
@@ -95,7 +97,7 @@ function bodyAt(ctx, f, k, A, anchor, now) {
|
|
|
95
97
|
// the same with keys or without), and no other body takes its place
|
|
96
98
|
const carry = best && carryOf(ctx, best.id);
|
|
97
99
|
if (!best || (carry?.side === k && ownGrip(ctx, carry.key, k, f) === null)) return null;
|
|
98
|
-
return anchor?.kind === "object" || best.d <= NEAR ? best.box : null;
|
|
100
|
+
return anchor?.kind === "object" || best.d <= NEAR ? { ...best.box, id: best.id } : null;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
const AXES = [0, 1, 2];
|
|
@@ -141,9 +143,13 @@ function comfortable(F, now, twist) {
|
|
|
141
143
|
*/
|
|
142
144
|
export function gripHand(ctx, sk, s, side, k, c, f, diag) {
|
|
143
145
|
// a wrist the spec turns on this frame is the agent's (or a skill's) own; a neutral 0 asks for nothing
|
|
144
|
-
|
|
145
|
-
const on = activeFit(ctx.spec, c, k);
|
|
146
|
+
// a hand that takes a grasp (socket-grip.js) yields only to the agent's own wrist keys, not to a skill's (a walk's swing)
|
|
147
|
+
const on = activeFit(ctx.spec, c, k), carry = socketCarry(ctx, k, f), grasp = on && on.w >= 1e-3 && graspAnchor(ctx.spec, on.fit.anchor);
|
|
148
|
+
if (carry || grasp ? ownWrist(ctx.spec, k) : Math.abs(c[`wtwist_${k}`] ?? 0) > 1e-3 || Math.abs(c[`wflex_${k}`] ?? 0) > 1e-3) return;
|
|
149
|
+
// a hand that holds what it carries by a socket keeps the grasp it took on the grab frame
|
|
150
|
+
if (carry && f > carry.grab) return carry.keyed ? undefined : keepSocketTurn(ctx, sk, s, side, k, carry);
|
|
146
151
|
if (!on || on.w < 1e-3) return;
|
|
152
|
+
if (grasp) return socketHand(ctx, sk, s, side, k, c, diag, on, st => effectorAt(sk, ctx.spec, st, side, on.fit.effector ?? {}), null, f);
|
|
147
153
|
// a hand that carries something on this frame holds that (its carry's grip), whatever its fit's anchor
|
|
148
154
|
if (Object.keys(ctx.spec.objects ?? {}).some(id => ctx.spec.objects[id]?.prop && ctx.objectHand?.(id, f) === k && ownGrip(ctx, id, k, f) === null)) return;
|
|
149
155
|
const now = { s, diag, c }, anchor = ctx.spec.anchors?.[on.fit.anchor];
|
|
@@ -156,6 +162,12 @@ export function gripHand(ctx, sk, s, side, k, c, f, diag) {
|
|
|
156
162
|
const measure = st => ({ pos: effectorAt(sk, ctx.spec, st, side, on.fit.effector ?? {}), R: (({ d, l, n }) => cols(d, l, n))(palmAxes(sk, st, side)) });
|
|
157
163
|
const I = sk.index, a = I[`${side}Arm`], fa = I[`${side}ForeArm`], h = I[`${side}Hand`];
|
|
158
164
|
const long = thinAxis(box.half), axis = long >= 0 ? apply(box.R, AXES.map(i => (i === long ? 1 : 0))) : null;
|
|
165
|
+
// an upright thin body (a cup, a can) under a plain anchor that nothing carries is held the way a grasp holds it
|
|
166
|
+
// (socket-grip.js): a power wrap from the side, not the hand over its top with the fingers down along the forearm
|
|
167
|
+
// (session 7f15b4ae). A carried one keeps its carry's grip, a lying bar or handle the roll rule below.
|
|
168
|
+
if (axis && Math.abs(axis[1]) > UPRIGHT && !carryOf(ctx, box.id)) {
|
|
169
|
+
return socketHand(ctx, sk, s, side, k, c, diag, on, st => effectorAt(sk, ctx.spec, st, side, on.fit.effector ?? {}), { w: { p: A, axis }, type: "power" }, f);
|
|
170
|
+
}
|
|
159
171
|
const ax = palmAxes(sk, s, side), roll = norm(flat(ax.l, ax.d));
|
|
160
172
|
const axT = norm(sub(s.P[h], s.P[fa])), neutral = mul(s.R[fa], mul(T(sk.restWorldRot[fa]), sk.restWorldRot[h]));
|
|
161
173
|
const twist = wristNeed(neutral, s.R[h], axT, apply(mul(neutral, T(s.R[h])), ax.l)).twist;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The hand of a fit whose anchor takes a grasp (an object anchor with `grasp`, ../sockets.js) holds the object by its
|
|
2
|
+
// socket: the palm frame comes from the socket (the knuckle line along its axis, the palm against the side the hand
|
|
3
|
+
// comes from), not from the forearm's roll rule (grip.js). With no side given, the hand rolls about the axis to where
|
|
4
|
+
// the arm holds it most easily, and where the forearm cannot come across the axis the knuckle line tilts on it (an
|
|
5
|
+
// oblique grip, priced, up to TILT deg). The fingers' point stays where the fit put it; the elbow swivels to the pole
|
|
6
|
+
// that leaves the wrist least strained (arm-orient.js, as a held object's hand, hold.js), the forearm takes its share of
|
|
7
|
+
// the turn and the wrist the rest within its limits. Weighted by the fit's weight, so the hand turns into the grasp on
|
|
8
|
+
// the reach and out of it on the release.
|
|
9
|
+
// A hand that carries what it holds by a socket without keys keeps, after the grab, the wrist turn it had on the grab
|
|
10
|
+
// frame against the forearm (diag.socket_<k>.rel, measured on the plain forearm before the turn): the object rides the
|
|
11
|
+
// palm as it was taken (object-pose.js keeps the grab-frame offset), so neither the hand nor the object turns back. A
|
|
12
|
+
// keyed carry's hand follows the keys (hold.js), whose grab offset is taken on that same grasp (object-keys.js attach).
|
|
13
|
+
import { sub, norm, mul, T, axisRot } from "./math.js";
|
|
14
|
+
import { palmAxes, twoBone } from "./limbs.js";
|
|
15
|
+
import { aimArm, orient } from "./hold.js";
|
|
16
|
+
import { ELBOW_REST, swivel } from "./arm-orient.js";
|
|
17
|
+
import { anchorSocket } from "./anchors.js";
|
|
18
|
+
import { socketPalm } from "../sockets.js";
|
|
19
|
+
import { partsOf } from "../object-pose.js";
|
|
20
|
+
|
|
21
|
+
const ITER = 3, ROLLS = 12, TEMP = 0.05, PULL = 0.3, DEG = 180 / Math.PI, TILT = 40, SLANT = 1, STEADY = 0.3;
|
|
22
|
+
const TILTS = [-TILT, -TILT / 2, 0, TILT / 2, TILT];
|
|
23
|
+
|
|
24
|
+
/** The Boltzmann-weighted mean of [value, cost] pairs at a softer temperature (STEADY): it moves smoothly between them. */
|
|
25
|
+
function mean(all) {
|
|
26
|
+
const low = Math.min(...all.map(x => x[1])), e = all.map(([a, c]) => [a, Math.exp(-(c - low) / STEADY)]);
|
|
27
|
+
return e.reduce((m, [a, x]) => m + a * x, 0) / e.reduce((m, [, x]) => m + x, 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The Boltzmann-weighted circular mean of ROLLS angles under cost(th) (second-hand.js softmin). */
|
|
31
|
+
function softmin(cost) {
|
|
32
|
+
const all = Array.from({ length: ROLLS }, (_, i) => { const th = (2 * Math.PI * i) / ROLLS - Math.PI; return [th, cost(th)]; }), low = Math.min(...all.map(x => x[1]));
|
|
33
|
+
const m = all.reduce((acc, [th, v]) => { const e = Math.exp(-(v - low) / TEMP); return [acc[0] + e * Math.cos(th), acc[1] + e * Math.sin(th)]; }, [0, 0]);
|
|
34
|
+
return Math.atan2(m[1], m[0]);
|
|
35
|
+
}
|
|
36
|
+
const cols = ({ d, l, n }) => [[d[0], l[0], n[0]], [d[1], l[1], n[1]], [d[2], l[2], n[2]]];
|
|
37
|
+
|
|
38
|
+
/** True when the anchor named `name` takes a grasp (an object anchor with `grasp`). */
|
|
39
|
+
export const graspAnchor = (spec, name) => spec.anchors?.[name]?.kind === "object" && !!spec.anchors[name].grasp;
|
|
40
|
+
|
|
41
|
+
/** True when the agent's own keys (spec.keys, not a skill's or a generator's) turn hand k's wrist (wtwist_<k>, wflex_<k>). */
|
|
42
|
+
export const ownWrist = (spec, k) => (spec.keys ?? []).some(key => (!key.src || key.src.startsWith("keys[")) && (key.set?.[`wtwist_${k}`] !== undefined || key.set?.[`wflex_${k}`] !== undefined));
|
|
43
|
+
|
|
44
|
+
/** True when a grasp fit of hand k is anchored on body or group `id` (on it, a part of it, or a group holding it). */
|
|
45
|
+
export function graspedBy(spec, scene, id, k) {
|
|
46
|
+
const parts = new Set(partsOf(scene, id));
|
|
47
|
+
return (spec.fits ?? []).some(fit => {
|
|
48
|
+
if (fit.side !== k || !graspAnchor(spec, fit.anchor)) return false;
|
|
49
|
+
const at = spec.anchors[fit.anchor].object;
|
|
50
|
+
return at === id || parts.has(at) || partsOf(scene, at).some(p => parts.has(p));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The carry hand k holds at frame f by a socket (graspedBy): { id, grab, keyed } for that spec.objects carry, else null. */
|
|
55
|
+
export function socketCarry(ctx, k, f) {
|
|
56
|
+
const { spec, scene } = ctx;
|
|
57
|
+
if (!ctx.objectHand || !(spec.fits ?? []).some(fit => fit.side === k && graspAnchor(spec, fit.anchor))) return null;
|
|
58
|
+
for (const [id, o] of Object.entries(spec.objects ?? {})) if (o?.prop && ctx.objectHand(id, f) === k && graspedBy(spec, scene, id, k)) return { id, grab: ctx.carrySpan(o)[0], keyed: !!o.keys?.length };
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* socketHand(ctx, sk, s, side, k, c, diag, on, effector, implicit, f): turn the hand of fit `on` ({ fit, w }) into its
|
|
64
|
+
* anchor's socket grasp (see above); effector(st) is the fit's effector point on a pose. `implicit` ({ w, type }) is
|
|
65
|
+
* the world socket and grasp of a fit without one (grip.js: an upright thin body under a plain anchor); f the frame.
|
|
66
|
+
*/
|
|
67
|
+
export function socketHand(ctx, sk, s, side, k, c, diag, on, effector, implicit = null, f = undefined) {
|
|
68
|
+
const I = sk.index, a = I[`${side}Arm`], fa = I[`${side}ForeArm`], h = I[`${side}Hand`], plain = s.R[fa];
|
|
69
|
+
const w = implicit?.w ?? anchorSocket(ctx, on.fit.anchor, s, diag, c, k), F = effector(s), fwd = norm(sub(s.P[h], s.P[fa]));
|
|
70
|
+
const type = implicit?.type ?? ctx.spec.anchors[on.fit.anchor].grasp, base = socketPalm(side, w, type, fwd, c.yaw);
|
|
71
|
+
const measure = st => ({ pos: effector(st), R: cols(palmAxes(sk, st, side)) }), now = measure(s);
|
|
72
|
+
// free to roll about its axis (no `from`): the roll the arm holds most easily, the Boltzmann mean of ROLLS rolls pulled
|
|
73
|
+
// lightly toward the fingers on the forearm's line, so the hand closes on the bar from the side the arm comes from
|
|
74
|
+
// then the knuckle line tilts on the bar (an oblique grip) up to TILT deg, priced, where the forearm cannot come across it
|
|
75
|
+
const strain = R => { const t = aimArm(sk, s, side, { pos: F, R }, now, 1, "palm"); return swivel(sk, s, side, t.goal, t.wristAt(t.goal), t.plane, ELBOW_REST).cost; };
|
|
76
|
+
const col = (M, i) => [M[0][i], M[1][i], M[2][i]], turned = th => mul(axisRot(col(base, 1), th * DEG), base);
|
|
77
|
+
const pick = () => {
|
|
78
|
+
const free = w.axis && !w.from && type !== "palm", roll = free ? softmin(th => strain(turned(th)) + PULL * (1 - Math.cos(th))) : 0;
|
|
79
|
+
const rolled = turned(roll), tilted = a => mul(axisRot(col(rolled, 2), a), rolled);
|
|
80
|
+
const tilt = w.axis && type !== "palm" ? mean(TILTS.map(a => [a, strain(tilted(a)) + SLANT * (a / TILT) ** 2])) : 0;
|
|
81
|
+
return { R: tilted(tilt), roll, tilt };
|
|
82
|
+
};
|
|
83
|
+
const { R, roll, tilt } = plannedGrasp(ctx, on.fit.anchor, k, f, pick);
|
|
84
|
+
const { goal, plane, wristAt } = aimArm(sk, s, side, { pos: F, R }, now, on.w, "palm");
|
|
85
|
+
const arm = swivel(sk, s, side, goal, wristAt(goal), plane, ELBOW_REST, { w: on.w });
|
|
86
|
+
let need = { twist: 0, flex: 0, dev: 0 };
|
|
87
|
+
for (let i = 0; i < ITER; i++) {
|
|
88
|
+
twoBone(sk, s, a, fa, h, wristAt(i ? s.R[h] : goal), arm.pole, ELBOW_REST, "elbow");
|
|
89
|
+
need = orient(sk, s, side, h, fa, goal);
|
|
90
|
+
}
|
|
91
|
+
diag[`grip_${k}`] = { w: on.w, socket: true, ...need, roll: roll * DEG, tilt };
|
|
92
|
+
diag[`socket_${k}`] = { rel: mul(T(plain), s.R[h]) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The grasp frame a reach turns the hand into, chosen once on the reach's first full-weight frame and kept on the
|
|
97
|
+
* approach to it (ctx.graspPlans, per bake): a person sets the hand's turn for the grasp at its end and keeps it on the way
|
|
98
|
+
* (end-state comfort), and a choice taken anew every frame flipped between two near-equal rolls as the fingers closed
|
|
99
|
+
* (a 5.5 cm wrist jump in one frame). pick() chooses it on the pose being solved: { R, roll, tilt }.
|
|
100
|
+
*/
|
|
101
|
+
function plannedGrasp(ctx, name, k, f, pick) {
|
|
102
|
+
const ch = `w_${name}_${k}`, at = g => ctx.weightAt?.(ch, g) ?? 0;
|
|
103
|
+
if (!ctx.weightAt || !Number.isInteger(f)) return pick();
|
|
104
|
+
let a = f, g;
|
|
105
|
+
while (a > 0 && at(a - 1) > 1e-3) a--;
|
|
106
|
+
for (g = a; g < ctx.frameCount - 1 && at(g) < 0.999 && at(g + 1) > 1e-3; g++);
|
|
107
|
+
const key = `${ch}:${a}`;
|
|
108
|
+
ctx.graspPlans ??= new Map();
|
|
109
|
+
// from the grasp on, the hand follows what it holds (carried, it moves): the plan covers the approach only
|
|
110
|
+
if (f >= g) { const plan = pick(); if (f === g) ctx.graspPlans.set(key, plan); return plan; }
|
|
111
|
+
if (!ctx.graspPlans.has(key)) { ctx.graspPlans.set(key, null); (ctx.hold?.plainFrame ?? ctx.solvedAt)(g); }
|
|
112
|
+
return ctx.graspPlans.get(key) ?? pick();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The hand of a socket carry after its grab keeps the grab frame's wrist turn against the forearm (see above), read
|
|
117
|
+
* once per bake (ctx.socketTurns) off the grab frame solved without the keys: a keyed carry's curve is built on
|
|
118
|
+
* frames solved without keys, which come here, so the keyed solve of the grab frame would need that curve again.
|
|
119
|
+
*/
|
|
120
|
+
export function keepSocketTurn(ctx, sk, s, side, k, carry) {
|
|
121
|
+
const key = `${k}:${carry.grab}`;
|
|
122
|
+
ctx.socketTurns ??= new Map();
|
|
123
|
+
if (!ctx.socketTurns.has(key)) {
|
|
124
|
+
ctx.socketTurns.set(key, null);
|
|
125
|
+
ctx.socketTurns.set(key, (ctx.hold?.plainFrame ?? ctx.solvedAt)(carry.grab)?.diag?.[`socket_${k}`]?.rel ?? null);
|
|
126
|
+
}
|
|
127
|
+
const rel = ctx.socketTurns.get(key);
|
|
128
|
+
if (!rel) return;
|
|
129
|
+
const I = sk.index, fa = I[`${side}ForeArm`], h = I[`${side}Hand`];
|
|
130
|
+
orient(sk, s, side, h, fa, mul(s.R[fa], rel));
|
|
131
|
+
}
|