cozyclay 3.0.1 → 3.0.2
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 +29 -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-DbKG0NgD.js +6 -0
- package/dist/assets/app-34i-iUy1.js +5088 -0
- package/dist/assets/compile-worker-DYpa2nvO.js +5 -0
- package/dist/assets/{first-shot-handoff-DNO8kv_N.js → first-shot-handoff-CrGZ3n_O.js} +1 -1
- package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
- package/dist/assets/{landing-yfZaJkWc.js → landing-mq5bnqFE.js} +1 -1
- package/dist/assets/scene-asset-sync-7J-w5oNh.js +20 -0
- package/dist/assets/{workflow-D78eiKTa.js → workflow-dUhurYUc.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/command-bus.js +8 -9
- 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/motion3.js +3 -1
- package/src/commands/objects.js +2 -2
- package/src/motion3/compiler/anchors.js +60 -4
- package/src/motion3/compiler/compile.js +5 -1
- package/src/motion3/compiler/grip.js +8 -2
- package/src/motion3/compiler/socket-grip.js +107 -0
- package/src/motion3/compiler/validate-parts.js +7 -2
- package/src/motion3/compiler/vocab.js +6 -0
- 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/scene.js +4 -3
- package/src/motion3/schema/fields.js +1 -1
- package/src/motion3/schema/topics.js +3 -2
- package/src/motion3/schema/types.js +2 -2
- package/src/motion3/skills/basic.js +8 -2
- package/src/motion3/skills/info.js +4 -4
- package/src/motion3/sockets.js +104 -0
- package/src/motion3/take-edit.js +1 -1
- package/src/motion3/take.js +6 -4
- 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 +1 -1
- package/src/studio-elements.js +3 -1
- 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/compile-worker-BxKXh07s.js +0 -5
- package/dist/assets/scene-asset-sync-U3kS2rvK.js +0 -20
|
@@ -1 +1 @@
|
|
|
1
|
-
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.
|
|
1
|
+
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.2\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"7232c78719451c250c0be94e5f375e56c3c05984e94882724c50048b900c6c71\"}","signature":"+LLNPJc5HYZikPGbfrI57GjEjarn3TRJiaHrrsn4z1CuRMN958copspe2aRT+Y3j5wBo1vH0gYI2UsYn2J6sBA=="}
|
package/dist/index.html
CHANGED
|
@@ -301,9 +301,9 @@
|
|
|
301
301
|
.btn, a.card, .seg button, .tutorial-steps li { transition: none; }
|
|
302
302
|
}
|
|
303
303
|
</style>
|
|
304
|
-
<script type="module" crossorigin src="/assets/landing-
|
|
305
|
-
<link rel="modulepreload" crossorigin href="/assets/analytics-
|
|
306
|
-
<link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-
|
|
304
|
+
<script type="module" crossorigin src="/assets/landing-mq5bnqFE.js"></script>
|
|
305
|
+
<link rel="modulepreload" crossorigin href="/assets/analytics-DbKG0NgD.js">
|
|
306
|
+
<link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-CrGZ3n_O.js">
|
|
307
307
|
</head>
|
|
308
308
|
<body>
|
|
309
309
|
<header class="site">
|
package/dist/workflow/index.html
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
<meta name="theme-color" content="#101116" />
|
|
9
9
|
<meta name="description" content="CozyClay visual workflow builder" />
|
|
10
10
|
<title>CozyClay Workflow</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/workflow-
|
|
11
|
+
<script type="module" crossorigin src="/assets/workflow-dUhurYUc.js"></script>
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
13
|
-
<link rel="modulepreload" crossorigin href="/assets/analytics-
|
|
13
|
+
<link rel="modulepreload" crossorigin href="/assets/analytics-DbKG0NgD.js">
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
|
|
15
|
-
<link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-
|
|
15
|
+
<link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-7J-w5oNh.js">
|
|
16
16
|
<link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-_S8NQl5B.css">
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
|
|
18
18
|
</head>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cozyclay",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
|
package/src/agent-turn-mark.js
CHANGED
|
@@ -23,8 +23,41 @@ export function markAgentTurn(id, turn) {
|
|
|
23
23
|
const { mark } = turn;
|
|
24
24
|
if (!mark) throw new StudioProtocolError('STALE_TARGET', 'The agent turn has no mark to roll back to.');
|
|
25
25
|
turn.mark = null;
|
|
26
|
+
turn.edits = (turn.edits ?? []).filter(edit => edit.mark !== mark);
|
|
26
27
|
// No session at the mark: everything the turn authored came after it.
|
|
27
28
|
turn.session?.rollback(mark.point ?? undefined);
|
|
28
29
|
turn.affectedIds = mark.affectedIds;
|
|
29
30
|
return { txId: turn.txId, affectedIds: [...mark.since], toasts: [] };
|
|
30
31
|
}
|
|
32
|
+
|
|
33
|
+
// The turn's own edits, newest last: each keeps the savepoint before it, so undo_edit on the
|
|
34
|
+
// newest one's receipt rolls back to there and the turn stays open (9240db36 was refused twice).
|
|
35
|
+
/** Where the turn stands before a command runs: the savepoint, the ids so far, the open mark. */
|
|
36
|
+
export const turnEditPoint = turn => turn && { point: turn.session?.savepoint() ?? null, affectedIds: new Set(turn.affectedIds), since: turn.mark && new Set(turn.mark.since), mark: turn.mark };
|
|
37
|
+
|
|
38
|
+
/** Files an authored receipt of the turn with the point before it; answers the receipt. */
|
|
39
|
+
export function recordTurnEdit(turn, at, recorded) {
|
|
40
|
+
if (turn && at && recorded?.ok && recorded.authored) (turn.edits ??= []).push({ ...at, receiptId: recorded.receiptId, ids: recorded.affectedIds });
|
|
41
|
+
return recorded;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* edit.undo from inside the owning turn once it has authored. A receipt of the turn's newest edit
|
|
46
|
+
* rolls back to the point before it and answers the receipt (before: the bus's read at admission;
|
|
47
|
+
* read: a read after); anything else is refused with what the agent can do instead.
|
|
48
|
+
*/
|
|
49
|
+
export function undoTurnEdit(turn, args, request, before, read) {
|
|
50
|
+
const edits = turn.edits ?? [], at = edits.findIndex(edit => edit.receiptId === args.receiptId);
|
|
51
|
+
if (at < 0) throw new StudioProtocolError('TARGET_BUSY', `Finish the agent turn before traversing history or opening another transaction. Inside the turn, undo_edit with the receiptId of this message's newest edit reverts it${edits.length ? ` (now ${edits.at(-1).receiptId})` : ''}; an earlier message's edit is undone after this one ends.`);
|
|
52
|
+
const edit = edits[at];
|
|
53
|
+
if (at < edits.length - 1) throw new StudioProtocolError('UNDO_CONFLICT', `Later edits of this message come after it; undo them first, newest first: ${edits.slice(at + 1).reverse().map(later => later.receiptId).join(', ')}.`);
|
|
54
|
+
turn.session.rollback(edit.point ?? undefined);
|
|
55
|
+
edits.pop();
|
|
56
|
+
turn.affectedIds = edit.affectedIds;
|
|
57
|
+
// Nothing came after a mark this edit predates, so the mark moves to now: a script's rollback still finds it.
|
|
58
|
+
if (turn.mark && edit.mark !== turn.mark) turn.mark = { point: turn.session.savepoint(), affectedIds: new Set(turn.affectedIds), since: new Set() };
|
|
59
|
+
else if (turn.mark) turn.mark.since = edit.since;
|
|
60
|
+
return { ok: true, status: 'completed', kind: 'transaction', action: 'edit.undo', txId: turn.txId, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
|
|
61
|
+
summary: `Undid ${edit.receiptId} inside this message.`, authored: false, revision: { before: before.revision, after: read().revision }, affectedIds: edit.ids, delta: [],
|
|
62
|
+
checks: { coverage: 'agent-turn-savepoint' }, undo: null, warnings: [], output: { undoneReceiptId: edit.receiptId } };
|
|
63
|
+
}
|
package/src/command-bus.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// idempotency. Synchronous UI actions stay synchronous (including user gestures).
|
|
4
4
|
import { HISTORY_LIMIT } from './history.js';
|
|
5
5
|
import { StudioProtocolError, StudioSchemas, validateStudioSchema, validateReceipt, validateStudioIdentity, refusalCode } from './studio-agent-protocol.js';
|
|
6
|
-
import { agentTurnControls, markAgentTurn } from './agent-turn-mark.js';
|
|
6
|
+
import { agentTurnControls, markAgentTurn, turnEditPoint, recordTurnEdit, undoTurnEdit } from './agent-turn-mark.js';
|
|
7
7
|
|
|
8
8
|
// A batching/retention adapter over the existing object history owner. The
|
|
9
9
|
// native store still owns every snapshot and transition; this tracks only the
|
|
@@ -102,12 +102,11 @@ export function createCommandBus({ registry, ports }) {
|
|
|
102
102
|
finally { agentTurn = null; }
|
|
103
103
|
return transactionReceipt(id, turn, request, turn.before);
|
|
104
104
|
}
|
|
105
|
-
// Every Studio turn opens a turn transaction (#715), so "undo the last thing" arrives
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// After the first authored edit the turn owns the frontier and traversal waits (#735).
|
|
105
|
+
// Every Studio turn opens a turn transaction (#715), so "undo the last thing" arrives inside one. Until the
|
|
106
|
+
// turn has authored (no session yet) history traversal is safe: the undo lands as its own entry (#735).
|
|
107
|
+
// After that the turn owns the frontier: its own agent may undo its newest edit (agent-turn-mark.js).
|
|
109
108
|
const traversal = id === 'edit.undo' || id === 'edit.redo';
|
|
110
|
-
if (agentTurn && !id.startsWith('job.') && !(traversal && !agentTurn.session)) fail('TARGET_BUSY', 'Finish the agent turn before traversing history or opening another transaction.');
|
|
109
|
+
if (agentTurn && !id.startsWith('job.') && !(traversal && !agentTurn.session)) { if (id === 'edit.undo' && request.turnId === agentTurn.txId) return validateReceipt(undoTurnEdit(agentTurn, args, request, before, () => ports.read())); fail('TARGET_BUSY', 'Finish the agent turn before traversing history or opening another transaction.'); }
|
|
111
110
|
if (traversal) {
|
|
112
111
|
const redo = id === 'edit.redo', historyEntryId = ports.history?.(redo);
|
|
113
112
|
const previous = args.receiptId ? ports.receipt(args.receiptId) : historyReceipts.get(historyEntryId)
|
|
@@ -358,13 +357,13 @@ export function createCommandBus({ registry, ports }) {
|
|
|
358
357
|
const deadline = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true }));
|
|
359
358
|
return Promise.race([value, deadline]);
|
|
360
359
|
};
|
|
361
|
-
const value = entry.kind === 'mutation' ? recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
|
|
360
|
+
const point = turnEditPoint(turn), value = entry.kind === 'mutation' ? recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
|
|
362
361
|
const finish = ({ result, historyEntryId }) => mapResult(result, output => {
|
|
363
362
|
clearTimer();
|
|
364
363
|
releaseToasts?.(); releaseToasts = null;
|
|
365
|
-
const refused = toastRefusal();
|
|
364
|
+
const refused = output?.output === undefined ? toastRefusal() : null; // a job that answered was not refused by a toast that showed meanwhile
|
|
366
365
|
if (refused) throw refused;
|
|
367
|
-
return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn));
|
|
366
|
+
return recordTurnEdit(turn, point, remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn)));
|
|
368
367
|
});
|
|
369
368
|
const finished = value?.then ? value.then(finish) : finish(value);
|
|
370
369
|
const answer = finished?.then ? finished.catch(rejected) : finished;
|
|
@@ -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/motion3.js
CHANGED
|
@@ -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 ?? [])];
|
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
|
|
@@ -9,18 +9,22 @@
|
|
|
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, 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 } from "../sockets.js";
|
|
27
|
+
import { partsOf } from "../object-pose.js";
|
|
24
28
|
|
|
25
29
|
function armReach(sk, k) {
|
|
26
30
|
const side = SIDES[k], P = sk.restWorldPos, I = sk.index;
|
|
@@ -81,10 +85,14 @@ function anchorPoint(ctx, a, name, s, diag, c, side) {
|
|
|
81
85
|
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
86
|
return at(a.tMin ?? 0.15);
|
|
83
87
|
}
|
|
84
|
-
case "object": {
|
|
88
|
+
case "object": {
|
|
89
|
+
if (a.grasp && a.local === undefined) return anchorSocket(ctx, name, s, diag, c, side).p;
|
|
90
|
+
const o = objectFrame(ctx, a, name, { s, diag, c }, side);
|
|
91
|
+
return add(o.pos, apply(o.R, a.local ?? [0, 0, 0]));
|
|
92
|
+
}
|
|
85
93
|
case "landmark": return bonePoint(s, I.Head, sk.landmarks[a.name] ?? (() => { throw new Error(`MOTION_SPEC_INVALID: unknown landmark "${a.name}"`); })());
|
|
86
94
|
case "prop": {
|
|
87
|
-
const p = diag.props?.[a.prop]?.[a.point];
|
|
95
|
+
const p = propOnCarry(ctx, a, name, diag?.frame) ?? diag.props?.[a.prop]?.[a.point];
|
|
88
96
|
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
97
|
return add(p, a.offset ?? [0, 0, 0]);
|
|
90
98
|
}
|
|
@@ -97,6 +105,54 @@ function anchorPoint(ctx, a, name, s, diag, c, side) {
|
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 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
|
|
110
|
+
* the object at the grab (or the release) nearest in time, carried with the object (at rest before the grab, let go
|
|
111
|
+
* after the release). In hand the point rides the palm; before the grab the palm still turns into its grip (and after
|
|
112
|
+
* the release out of it), and a point held off it swept through the other arm's reach: session 02263581's barbell put
|
|
113
|
+
* 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
|
|
114
|
+
* release. Read off the object, the point is where the left hand takes the bar, and meets the palm's at the grab. null
|
|
115
|
+
* when no carry of that hand applies (the palm's point is used).
|
|
116
|
+
*/
|
|
117
|
+
function propOnCarry(ctx, a, name, f) {
|
|
118
|
+
const hand = ctx.spec.props?.[a.prop]?.hand;
|
|
119
|
+
if (!hand || f === undefined || !ctx.solvedAt || !ctx.carrySpan) return null;
|
|
120
|
+
let pick = null;
|
|
121
|
+
for (const [id, o] of Object.entries(ctx.spec.objects ?? {})) {
|
|
122
|
+
if (!isCarried(o) || ctx.spec.props?.[o.prop]?.hand !== hand) continue;
|
|
123
|
+
const [grab, release] = ctx.carrySpan(o);
|
|
124
|
+
if (f >= grab && f <= release) return null;
|
|
125
|
+
const at = f < grab ? grab : release;
|
|
126
|
+
if (!pick || Math.abs(f - at) < Math.abs(f - pick.at)) pick = { id, at };
|
|
127
|
+
}
|
|
128
|
+
if (!pick) return null;
|
|
129
|
+
const there = nested(ctx, name, pick.id, pick.at, () => ctx.solvedAt(pick.at).diag.props?.[a.prop]?.[a.point]);
|
|
130
|
+
const was = ctx.objectPose(pick.id, pick.at), now = ctx.objectPose(pick.id, f);
|
|
131
|
+
return there && was && now ? add(now.pos, apply(now.R, apply(T(was.R), sub(there, was.pos)))) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The socket (../sockets.js) an object anchor with `grasp` takes the hand by, in world space on its object's pose that
|
|
136
|
+
* frame: { p, axis?, from?, thumb? }, p the anchor's point (its `local` when it has one). Its `socket`, else the one
|
|
137
|
+
* nearest its `local` point or, without one, nearest the shoulder of the hand: picked once per compile (ctx.sockets),
|
|
138
|
+
* so the hand does not hop between sockets. An object with no socket gives the bare point: the palm faces it.
|
|
139
|
+
*/
|
|
140
|
+
export function anchorSocket(ctx, name, s, diag, c, side) {
|
|
141
|
+
const a = ctx.spec.anchors[name], now = { s, diag, c }, poseOf = id => objectFrame(ctx, { ...a, object: id }, name, now, side);
|
|
142
|
+
const local = a.local && (o => add(o.pos, apply(o.R, a.local)))(poseOf(a.object));
|
|
143
|
+
ctx.sockets ??= new Map();
|
|
144
|
+
if (!ctx.sockets.has(name)) {
|
|
145
|
+
const all = socketsOf(ctx.scene, a.object), I = ctx.sk.index, near = local ?? s.P[I[side ? `${SIDES[side]}Arm` : "Hips"]];
|
|
146
|
+
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;
|
|
147
|
+
ctx.sockets.set(name, pick ?? null);
|
|
148
|
+
}
|
|
149
|
+
const sock = ctx.sockets.get(name), w = sock ? socketWorld(sock, poseOf(sock.owner)) : { p: local ?? poseOf(a.object).pos };
|
|
150
|
+
if (local) w.p = local;
|
|
151
|
+
// 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
|
|
152
|
+
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);
|
|
153
|
+
return w;
|
|
154
|
+
}
|
|
155
|
+
|
|
100
156
|
/** Points of every prop held by hand k, in world space, written to diag.props. */
|
|
101
157
|
export function measureProps(ctx, k, s, diag) {
|
|
102
158
|
const { sk } = ctx;
|
|
@@ -33,7 +33,7 @@ import { fixOptions, applyFixes } from "../fix/index.js";
|
|
|
33
33
|
import { fitWeight } from "../fix/self.js";
|
|
34
34
|
import { holdHook } from "../object-keys.js";
|
|
35
35
|
import { HAND_CLEAR, keepOut, wristBody } from "../body.js";
|
|
36
|
-
import { objectPoseAt, handOf, restingAt } from "../object-pose.js";
|
|
36
|
+
import { objectPoseAt, handOf, restingAt, frameAt, spanOf } from "../object-pose.js";
|
|
37
37
|
|
|
38
38
|
const smooth = x => x * x * (3 - 2 * x);
|
|
39
39
|
|
|
@@ -180,10 +180,14 @@ export function compileMotion(spec, { sk, scene }, opts = {}) {
|
|
|
180
180
|
ctx.objectHand = (id, f) => handOf(out, id, f);
|
|
181
181
|
// a keyed carry's grab offset is taken where it rests at grab (object-keys.js; a fall before grab: where it landed)
|
|
182
182
|
ctx.restingAt = (id, f) => restingAt(out, id, f);
|
|
183
|
+
// a prop anchor outside its hand's carry reads the carry's grab (release) frame (anchors.js propOnCarry)
|
|
184
|
+
ctx.carrySpan = o => spanOf(out, o);
|
|
185
|
+
ctx.solvedAt = f => frameAt(out, f);
|
|
183
186
|
const engine = {
|
|
184
187
|
rebake(keys) {
|
|
185
188
|
out.keys = keys;
|
|
186
189
|
out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow));
|
|
190
|
+
ctx.socketTurns = new Map();
|
|
187
191
|
steadyHead(out.baked, keys, frameCount);
|
|
188
192
|
// a held pose breathes and shifts its weight (idle.js): offsets solveFrame adds, contacts and out.baked stay as keyed
|
|
189
193
|
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,6 +32,7 @@ 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
37
|
const NEAR = 0.06, 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]]];
|
|
@@ -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 ?? {}));
|
|
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];
|
|
@@ -0,0 +1,107 @@
|
|
|
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): turn the hand of fit `on` ({ fit, w }) into its anchor's
|
|
64
|
+
* socket grasp (see above); effector(st) is the fit's effector point on a pose.
|
|
65
|
+
*/
|
|
66
|
+
export function socketHand(ctx, sk, s, side, k, c, diag, on, effector) {
|
|
67
|
+
const I = sk.index, a = I[`${side}Arm`], fa = I[`${side}ForeArm`], h = I[`${side}Hand`], plain = s.R[fa];
|
|
68
|
+
const w = anchorSocket(ctx, on.fit.anchor, s, diag, c, k), F = effector(s), fwd = norm(sub(s.P[h], s.P[fa]));
|
|
69
|
+
const type = ctx.spec.anchors[on.fit.anchor].grasp, base = socketPalm(side, w, type, fwd, c.yaw);
|
|
70
|
+
const measure = st => ({ pos: effector(st), R: cols(palmAxes(sk, st, side)) }), now = measure(s);
|
|
71
|
+
// free to roll about its axis (no `from`): the roll the arm holds most easily, the Boltzmann mean of ROLLS rolls pulled
|
|
72
|
+
// lightly toward the fingers on the forearm's line, so the hand closes on the bar from the side the arm comes from
|
|
73
|
+
// then the knuckle line tilts on the bar (an oblique grip) up to TILT deg, priced, where the forearm cannot come across it
|
|
74
|
+
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; };
|
|
75
|
+
const col = (M, i) => [M[0][i], M[1][i], M[2][i]], turned = th => mul(axisRot(col(base, 1), th * DEG), base);
|
|
76
|
+
const free = w.axis && !w.from && type !== "palm", roll = free ? softmin(th => strain(turned(th)) + PULL * (1 - Math.cos(th))) : 0;
|
|
77
|
+
const rolled = turned(roll), tilted = a => mul(axisRot(col(rolled, 2), a), rolled);
|
|
78
|
+
const tilt = w.axis && type !== "palm" ? mean(TILTS.map(a => [a, strain(tilted(a)) + SLANT * (a / TILT) ** 2])) : 0;
|
|
79
|
+
const R = tilted(tilt);
|
|
80
|
+
const { goal, plane, wristAt } = aimArm(sk, s, side, { pos: F, R }, now, on.w, "palm");
|
|
81
|
+
const arm = swivel(sk, s, side, goal, wristAt(goal), plane, ELBOW_REST, { w: on.w });
|
|
82
|
+
let need = { twist: 0, flex: 0, dev: 0 };
|
|
83
|
+
for (let i = 0; i < ITER; i++) {
|
|
84
|
+
twoBone(sk, s, a, fa, h, wristAt(i ? s.R[h] : goal), arm.pole, ELBOW_REST, "elbow");
|
|
85
|
+
need = orient(sk, s, side, h, fa, goal);
|
|
86
|
+
}
|
|
87
|
+
diag[`grip_${k}`] = { w: on.w, socket: true, ...need };
|
|
88
|
+
diag[`socket_${k}`] = { rel: mul(T(plain), s.R[h]) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The hand of a socket carry after its grab keeps the grab frame's wrist turn against the forearm (see above), read
|
|
93
|
+
* once per bake (ctx.socketTurns) off the grab frame solved without the keys: a keyed carry's curve is built on
|
|
94
|
+
* frames solved without keys, which come here, so the keyed solve of the grab frame would need that curve again.
|
|
95
|
+
*/
|
|
96
|
+
export function keepSocketTurn(ctx, sk, s, side, k, carry) {
|
|
97
|
+
const key = `${k}:${carry.grab}`;
|
|
98
|
+
ctx.socketTurns ??= new Map();
|
|
99
|
+
if (!ctx.socketTurns.has(key)) {
|
|
100
|
+
ctx.socketTurns.set(key, null);
|
|
101
|
+
ctx.socketTurns.set(key, (ctx.hold?.plainFrame ?? ctx.solvedAt)(carry.grab)?.diag?.[`socket_${k}`]?.rel ?? null);
|
|
102
|
+
}
|
|
103
|
+
const rel = ctx.socketTurns.get(key);
|
|
104
|
+
if (!rel) return;
|
|
105
|
+
const I = sk.index, fa = I[`${side}ForeArm`], h = I[`${side}Hand`];
|
|
106
|
+
orient(sk, s, side, h, fa, mul(s.R[fa], rel));
|
|
107
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Validation of the spec parts other than keys: anchors, props, fits, generators, checks and
|
|
2
2
|
// fix; objects (validate-objects.js) and goals (validate-goals.js) run from here too. Unknown
|
|
3
3
|
// fields go through opts.unknown, which ignores them on a lenient reload. See validate.js.
|
|
4
|
-
import { didYouMean, suggestField, CONVENTIONS, isVec3, fmt } from "./vocab.js";
|
|
4
|
+
import { didYouMean, suggestField, CONVENTIONS, isVec3, fmt, GRASPS } from "./vocab.js";
|
|
5
5
|
import { FIX_KEYS } from "../fix/index.js";
|
|
6
6
|
import { QA_LIMITS } from "../qa/checks.js";
|
|
7
7
|
import { ANCHOR_FIELDS, PROP_FIELDS, FIT_FIELDS, WALK_FIELDS, CHECK_FIELDS, CHECK_ENTRY_FIELDS } from "../schema/fields.js";
|
|
@@ -9,6 +9,7 @@ import { boneOk, objectOk, refOk } from "./validate-common.js";
|
|
|
9
9
|
import { validateObjects } from "./validate-objects.js";
|
|
10
10
|
import { validateGoals } from "./validate-goals.js";
|
|
11
11
|
import { personJoint, peopleList } from "../people.js";
|
|
12
|
+
import { socketsOf, socketNamed } from "../sockets.js";
|
|
12
13
|
|
|
13
14
|
const SIDE = new Set(["l", "r"]);
|
|
14
15
|
const isObject = v => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
@@ -29,7 +30,11 @@ function validateAnchors(spec, err, { sk, scene, unknown = err }) {
|
|
|
29
30
|
if ((a.kind === "world" || a.kind === "body") && a.p === undefined) err(`${at} (${a.kind}) needs p: [x, y, z]${a.kind === "body" ? `, ${CONVENTIONS.pole}` : ""}`);
|
|
30
31
|
if (a.kind === "bone") boneOk(err, sk, at, a.bone);
|
|
31
32
|
if (a.kind === "segment") { boneOk(err, sk, `${at}.from`, a.from); boneOk(err, sk, `${at}.to`, a.to); if (a.reach !== undefined && !SIDE.has(a.reach)) err(`${at}.reach must be "l" or "r"`); }
|
|
32
|
-
if (a.kind === "object"
|
|
33
|
+
if (a.kind === "object" && objectOk(err, scene, at, a.object, { groups: true })) {
|
|
34
|
+
if (a.grasp !== undefined && !GRASPS.includes(a.grasp)) err(`${at}.grasp must be one of ${GRASPS.join(", ")}, got ${fmt(a.grasp)}`);
|
|
35
|
+
if (a.socket !== undefined && scene?.objects && !socketNamed(scene, a.object, a.socket)) err(`${at}.socket: "${a.object}" has no socket ${fmt(a.socket)} (sockets: ${socketsOf(scene, a.object).map(k => k.name).join(", ") || "none"})`);
|
|
36
|
+
if (a.socket !== undefined && a.grasp === undefined) err(`${at}.socket needs grasp (${GRASPS.join(", ")}): the socket says how the hand takes it`);
|
|
37
|
+
}
|
|
33
38
|
if (a.kind === "person" && !personJoint(scene, a.person, a.bone ?? "", 0)) err(`${at} (person) needs person: the id of another character with an installed take (people: ${peopleList(scene)}) and bone: one of its joints, got ${fmt(a.person)} ${fmt(a.bone)}`);
|
|
34
39
|
if (a.kind === "landmark" && a.name !== "mouth") err(`${at}: the only landmark is "mouth", got ${fmt(a.name)}`);
|
|
35
40
|
if (a.kind === "prop" && !spec.props?.[a.prop]?.points?.[a.point] && a.point !== "origin") err(`${at} needs prop point ${a.prop}.${a.point}, which spec.props does not define`);
|
|
@@ -11,6 +11,12 @@ export const MAP_CHANNELS = new Set(sided(["fingers"]));
|
|
|
11
11
|
// Channels the skills key (standIdle, walks): valid in a spec, left out of motion.describe so a first spec need not read them.
|
|
12
12
|
export const SKILL_CHANNELS = new Set(["arm_hang"]);
|
|
13
13
|
export const FINGER_PRESETS = ["relaxed", "fist", "grip", "cigarette", "point"];
|
|
14
|
+
/** The grasps an object anchor takes a hand by (../sockets.js), and the finger preset each closes the hand to by default. */
|
|
15
|
+
export const GRASPS = ["power", "pinch", "hook", "palm"];
|
|
16
|
+
export const GRASP_FINGERS = { power: "grip", pinch: "cigarette", hook: "grip", palm: "relaxed" };
|
|
17
|
+
/** Where the held thing sits in the hand, [d, l, n] on the palm axes from the finger middles (n toward the palm; m): a bar in a
|
|
18
|
+
* power grip or a hook lies across the palm under the finger middles, a pinch is held at the fingertips, a flat palm presses with its centre. */
|
|
19
|
+
export const GRASP_POINTS = { power: [0, 0, 0.02], hook: [0, 0, 0.02], pinch: [0.025, 0, 0.01], palm: [-0.045, 0, 0.015] };
|
|
14
20
|
|
|
15
21
|
// [min, max, convention] for numeric channels whose values have a hard range.
|
|
16
22
|
const RANGES = [
|