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.
Files changed (67) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/bin/agent/pi-tools.mjs +12 -1
  4. package/bin/agent/studio-context-budget.mjs +8 -2
  5. package/bin/agent/studio-prompt.mjs +1 -1
  6. package/bin/agent/studio-tools.mjs +1 -1
  7. package/dist/app/index.html +4 -4
  8. package/dist/assets/analytics-DbKG0NgD.js +6 -0
  9. package/dist/assets/app-34i-iUy1.js +5088 -0
  10. package/dist/assets/compile-worker-DYpa2nvO.js +5 -0
  11. package/dist/assets/{first-shot-handoff-DNO8kv_N.js → first-shot-handoff-CrGZ3n_O.js} +1 -1
  12. package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
  13. package/dist/assets/{landing-yfZaJkWc.js → landing-mq5bnqFE.js} +1 -1
  14. package/dist/assets/scene-asset-sync-7J-w5oNh.js +20 -0
  15. package/dist/assets/{workflow-D78eiKTa.js → workflow-dUhurYUc.js} +1 -1
  16. package/dist/cozyclay-package.json +1 -1
  17. package/dist/index.html +3 -3
  18. package/dist/workflow/index.html +3 -3
  19. package/package.json +1 -1
  20. package/src/agent-turn-mark.js +33 -0
  21. package/src/command-bus.js +8 -9
  22. package/src/commands/elements/character.js +2 -1
  23. package/src/commands/elements/motion.js +7 -1
  24. package/src/commands/elements/scene.js +3 -0
  25. package/src/commands/elements/stage.js +2 -1
  26. package/src/commands/elements/take-read.js +31 -0
  27. package/src/commands/elements.js +16 -2
  28. package/src/commands/motion3.js +3 -1
  29. package/src/commands/objects.js +2 -2
  30. package/src/motion3/compiler/anchors.js +60 -4
  31. package/src/motion3/compiler/compile.js +5 -1
  32. package/src/motion3/compiler/grip.js +8 -2
  33. package/src/motion3/compiler/socket-grip.js +107 -0
  34. package/src/motion3/compiler/validate-parts.js +7 -2
  35. package/src/motion3/compiler/vocab.js +6 -0
  36. package/src/motion3/free-motion.js +12 -4
  37. package/src/motion3/object-keys.js +36 -5
  38. package/src/motion3/object-pose.js +38 -5
  39. package/src/motion3/scene.js +4 -3
  40. package/src/motion3/schema/fields.js +1 -1
  41. package/src/motion3/schema/topics.js +3 -2
  42. package/src/motion3/schema/types.js +2 -2
  43. package/src/motion3/skills/basic.js +8 -2
  44. package/src/motion3/skills/info.js +4 -4
  45. package/src/motion3/sockets.js +104 -0
  46. package/src/motion3/take-edit.js +1 -1
  47. package/src/motion3/take.js +6 -4
  48. package/src/object-catalog.jsx +3 -1
  49. package/src/object-grasp.js +38 -0
  50. package/src/object-look.js +2 -0
  51. package/src/object-travel.js +6 -3
  52. package/src/panels/ObjectLookPanel.jsx +24 -3
  53. package/src/props.jsx +5 -5
  54. package/src/scene-objects.js +19 -11
  55. package/src/shell/details-selection.js +2 -1
  56. package/src/studio-agent-protocol.js +5 -6
  57. package/src/studio-app-binding.js +1 -1
  58. package/src/studio-elements.js +3 -1
  59. package/src/text/fonts/helvetiker-LICENSE.txt +13 -0
  60. package/src/text/fonts/helvetiker-bold.typeface.json +1 -0
  61. package/src/text/text-object.jsx +106 -0
  62. package/src/text/text-record.js +122 -0
  63. package/tools/run-tests.mjs +1 -1
  64. package/dist/assets/analytics-BWCqdgWA.js +0 -1
  65. package/dist/assets/app-DfTCxaKk.js +0 -5087
  66. package/dist/assets/compile-worker-BxKXh07s.js +0 -5
  67. package/dist/assets/scene-asset-sync-U3kS2rvK.js +0 -20
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"bedf955b7367c9d4e883baa9c0b6fd14a205f6d39a3bf447f628488cd2893564\"}","signature":"+2TEXB+bXFUPN34/ls1YvimKkHfSdSCs0IiIyjIhsHqvz3j+XcKkEkqx1zjXn8MQlybgStGOS290YH40Nw1hBQ=="}
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-yfZaJkWc.js"></script>
305
- <link rel="modulepreload" crossorigin href="/assets/analytics-BWCqdgWA.js">
306
- <link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-DNO8kv_N.js">
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">
@@ -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-D78eiKTa.js"></script>
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-BWCqdgWA.js">
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-U3kS2rvK.js">
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.1",
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",
@@ -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
+ }
@@ -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
- // inside one. Until the turn has authored anything (no session yet) history traversal
107
- // is safe: the undo lands as its own entry and a later edit still groups into the turn.
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
- registerElementKind('motion', { collection: true, elements, normalize: row => ({ ...row, take: row.take && { ...row.take } }) });
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);
@@ -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
- export function readElementDocument(projection, { ids, select } = {}, sceneId) {
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
- return { document: structuredClone(Object.fromEntries(entries)), schema: Object.fromEntries(entries.map(([key]) => [key, elementSetSchema(projectionKind(key))])) };
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);
@@ -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
- const patched = [], source = spec ?? asArgument(() => applyPatch(store.get(base) ?? unknownSpec("base", base), patch ?? [], patched));
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 ?? [])];
@@ -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": { const o = objectFrame(ctx, a, name, { s, diag, c }, side); return add(o.pos, apply(o.R, a.local ?? [0, 0, 0])); }
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
- if (Math.abs(c[`wtwist_${k}`] ?? 0) > 1e-3 || Math.abs(c[`wflex_${k}`] ?? 0) > 1e-3) return;
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") objectOk(err, scene, at, a.object, { groups: true });
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 = [