@pinepaper.studio/mcp-server 1.6.6 → 1.6.7

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 (55) hide show
  1. package/README.de.md +20 -1
  2. package/README.es.md +20 -1
  3. package/README.fr.md +20 -1
  4. package/README.hi.md +18 -1
  5. package/README.ja.md +19 -1
  6. package/README.ko.md +18 -1
  7. package/README.md +166 -5
  8. package/README.pt-BR.md +20 -1
  9. package/README.zh-CN.md +18 -1
  10. package/assets/edit-whenever.svg +75 -0
  11. package/dist/agent/export-manager.d.ts.map +1 -1
  12. package/dist/agent/export-manager.js +2 -2
  13. package/dist/agent/export-manager.js.map +1 -1
  14. package/dist/agent/session-manager.d.ts.map +1 -1
  15. package/dist/agent/session-manager.js.map +1 -1
  16. package/dist/assets/repositories/fontawesome.d.ts.map +1 -1
  17. package/dist/assets/repositories/fontawesome.js.map +1 -1
  18. package/dist/assets/repositories/openclipart.d.ts.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +0 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/ontology/design-graph.d.ts.map +1 -1
  23. package/dist/ontology/design-graph.js +0 -1
  24. package/dist/ontology/design-graph.js.map +1 -1
  25. package/dist/ontology/vocabulary.d.ts.map +1 -1
  26. package/dist/ontology/vocabulary.js +119 -0
  27. package/dist/ontology/vocabulary.js.map +1 -1
  28. package/dist/taxonomy/classifier.js +1 -1
  29. package/dist/taxonomy/classifier.js.map +1 -1
  30. package/dist/taxonomy/error-recovery.d.ts.map +1 -1
  31. package/dist/taxonomy/matcher.d.ts.map +1 -1
  32. package/dist/taxonomy/matcher.js.map +1 -1
  33. package/dist/tools/definitions.d.ts +1 -1
  34. package/dist/tools/definitions.d.ts.map +1 -1
  35. package/dist/tools/definitions.js +906 -34
  36. package/dist/tools/definitions.js.map +1 -1
  37. package/dist/tools/handlers.d.ts.map +1 -1
  38. package/dist/tools/handlers.js +71 -7
  39. package/dist/tools/handlers.js.map +1 -1
  40. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  41. package/dist/tools/minimal-descriptions.js +11 -2
  42. package/dist/tools/minimal-descriptions.js.map +1 -1
  43. package/dist/tools/toolkits.d.ts.map +1 -1
  44. package/dist/tools/toolkits.js +14 -0
  45. package/dist/tools/toolkits.js.map +1 -1
  46. package/dist/types/code-generator.d.ts +46 -2
  47. package/dist/types/code-generator.d.ts.map +1 -1
  48. package/dist/types/code-generator.js +1004 -24
  49. package/dist/types/code-generator.js.map +1 -1
  50. package/dist/types/schemas.d.ts +2266 -215
  51. package/dist/types/schemas.d.ts.map +1 -1
  52. package/dist/types/schemas.js +553 -50
  53. package/dist/types/schemas.js.map +1 -1
  54. package/manifest.json +37 -1
  55. package/package.json +8 -7
@@ -5,7 +5,7 @@
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
7
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
8
- import { CreateItemInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
8
+ import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
9
9
  // Diagram types
10
10
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
11
11
  // Agent flow mode types
@@ -21,7 +21,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
21
21
  // Custom relation/code types
22
22
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
23
23
  // Mask types
24
- ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, } from './schemas.js';
24
+ ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, } from './schemas.js';
25
25
  import { OntologyCompiler } from '../ontology/ontology-compiler.js';
26
26
  // =============================================================================
27
27
  // HELPER FUNCTIONS
@@ -97,6 +97,36 @@ function generateCreateItemCode(itemType, position, properties) {
97
97
  if (strokeColor && !isGradient(strokeColor)) {
98
98
  params.strokeColor = strokeColor;
99
99
  }
100
+ // RENDER-TIME SURFACES DEGRADE, THEY DO NOT FAIL.
101
+ //
102
+ // `shader` and `field` are drawn per pixel by the cloud rasterizer from the
103
+ // scene document; the browser engine has no such item type and answers
104
+ // `create('shader')` with "unknown type — nothing was created". Emitting that
105
+ // call would hand every local user a silent hole where their backdrop was.
106
+ //
107
+ // So locally they become a plate: a rectangle carrying the same id, position,
108
+ // size and colour, which keeps the composition readable and the item
109
+ // addressable by every later call. The scene document still says `shader`, so
110
+ // a cloud render draws the real surface. The divergence is declared here
111
+ // rather than discovered in a frame.
112
+ if (itemType === 'shader' || itemType === 'field') {
113
+ const plate = {
114
+ x: position.x,
115
+ y: position.y,
116
+ width: params.width ?? 200,
117
+ height: params.height ?? 200,
118
+ fillColor: params.fillColor ?? params.fill ?? '#334155',
119
+ };
120
+ if (params.id)
121
+ plate.id = params.id;
122
+ if (params.opacity != null)
123
+ plate.opacity = params.opacity;
124
+ return `
125
+ // ${itemType} — a render-time surface. Drawn per pixel by the cloud renderer;
126
+ // stood in for locally by a flat plate so the layout still reads.
127
+ const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
128
+ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }`;
129
+ }
100
130
  // Build the code
101
131
  let code = `
102
132
  // Create ${itemType} item
@@ -309,7 +339,23 @@ app.animate(item, {
309
339
  animationSpeed: ${speed}${extra}
310
340
  });
311
341
 
312
- ({ success: true, itemId: '${itemId}', animationType: '${animationType}' });
342
+ // The engine records a refused type on the item rather than throwing, because
343
+ // animate() is on too many paths for a throw to be safe. It used to warn only
344
+ // to a console the production build strips, so an unknown type left the item
345
+ // still and every caller told it had worked. Read the record back.
346
+ (() => {
347
+ const r = item.data && item.data.animationRejected;
348
+ if (r) {
349
+ return {
350
+ success: false,
351
+ itemId: '${itemId}',
352
+ error: 'Unknown animationType "' + r.requested + '" — nothing will move.',
353
+ requested: r.requested,
354
+ known: r.known,
355
+ };
356
+ }
357
+ return { success: true, itemId: '${itemId}', animationType: '${animationType}' };
358
+ })();
313
359
  `.trim();
314
360
  }
315
361
  /**
@@ -353,6 +399,36 @@ app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
353
399
  /**
354
400
  * Template for executing generators
355
401
  */
402
+ function generateImportMotionCaptureCode(bvh, mode, skeletonId, opts = {}) {
403
+ // Both FxTool entry points are async and return {ok, ...} rather than throwing, so the failure
404
+ // path is a value the model can read (unmatchedSource/unmatchedTarget tell it exactly which bone
405
+ // names to put in boneMap) instead of an opaque exception.
406
+ const bvhLiteral = JSON.stringify(bvh);
407
+ const optsLiteral = JSON.stringify(opts);
408
+ if (mode === 'retarget') {
409
+ return `
410
+ // Retarget a BVH clip onto an existing rig — proportions from the rig, motion from the capture
411
+ (async function() {
412
+ const res = await app.retargetBVH(${bvhLiteral}, ${JSON.stringify(skeletonId ?? '')}, ${optsLiteral});
413
+ if (!res || res.ok === false) {
414
+ return { success: false, error: (res && res.error) || 'retarget failed',
415
+ unmatchedSource: res && res.unmatchedSource, unmatchedTarget: res && res.unmatchedTarget };
416
+ }
417
+ return { success: true, skeletonId: res.skeletonId, matched: res.matched,
418
+ unmatchedSource: res.unmatchedSource, unmatchedTarget: res.unmatchedTarget };
419
+ })()`;
420
+ }
421
+ return `
422
+ // Import a BVH clip as a NEW skeleton
423
+ (async function() {
424
+ const res = await app.importBVH(${bvhLiteral}, ${optsLiteral});
425
+ if (!res || res.ok === false) {
426
+ return { success: false, error: (res && res.error) || 'import failed' };
427
+ }
428
+ return { success: true, skeletonId: res.skeletonId, bones: res.bones, poses: res.poses,
429
+ duration: res.duration };
430
+ })()`;
431
+ }
356
432
  function generateExecuteGeneratorCode(generatorName, params, region) {
357
433
  // FxTool signature: app.executeGenerator(name, params, options).
358
434
  // region lives in options (added in FxTool c81781c).
@@ -427,11 +503,120 @@ const items = filtered.map(entry => ({
427
503
  ({ items, count: items.length });
428
504
  `.trim();
429
505
  }
430
- /**
431
- * Template for timeline control
432
- */
433
- function generatePlayTimelineCode(action, duration, loop, time, deterministic) {
506
+ // Where a scroll binding parks its unbind function. `bindTimelineToScroll`
507
+ // returns a FUNCTION, which cannot cross the tool boundary — so it lives on the
508
+ // page, where a later unbind_scroll call can still reach it.
509
+ const SCRUB_UNBIND_KEY = '__ppScrubUnbind';
510
+ function generatePlayTimelineCode(action, duration, loop, time, deterministic, extra) {
511
+ const S = (v) => JSON.stringify(v);
434
512
  switch (action) {
513
+ case 'set_time_scale': {
514
+ // Deliberately not clamped: 0 freezes the clock without stopping, and a
515
+ // negative rate runs the scene backwards.
516
+ const rate = extra?.rate ?? 1;
517
+ return `
518
+ // pp:TimeScale — the rate the scene clock runs at.
519
+ (function() {
520
+ if (typeof app.setTimeScale !== 'function') {
521
+ return { success: false, error: 'app.setTimeScale unavailable — update FxTool' };
522
+ }
523
+ // Changing the rate rebases the clock so the CURRENT position is preserved;
524
+ // without that, doubling the rate mid-play jumps the playhead to twice its
525
+ // elapsed time, which reads as a glitch rather than a speed change.
526
+ const rate = app.setTimeScale(${S(rate)});
527
+ // Export drives time itself, so a scene watched at 0.5x still exports its
528
+ // real duration rather than a file twice as long.
529
+ return { success: true, action: 'set_time_scale', rate, affectsExport: false };
530
+ })();
531
+ `.trim();
532
+ }
533
+ case 'get_time_scale':
534
+ return `
535
+ (function() {
536
+ if (typeof app.getTimeScale !== 'function') {
537
+ return { success: false, error: 'app.getTimeScale unavailable — update FxTool' };
538
+ }
539
+ return { success: true, action: 'get_time_scale', rate: app.getTimeScale() };
540
+ })();
541
+ `.trim();
542
+ case 'get_progress':
543
+ return `
544
+ (function() {
545
+ if (typeof app.getProgress !== 'function') {
546
+ return { success: false, error: 'app.getProgress unavailable — update FxTool' };
547
+ }
548
+ return { success: true, action: 'get_progress', progress: app.getProgress() };
549
+ })();
550
+ `.trim();
551
+ case 'set_progress':
552
+ return `
553
+ // Seek by proportion rather than by seconds — the unit a scrubber has.
554
+ (function() {
555
+ if (typeof app.setProgress !== 'function') {
556
+ return { success: false, error: 'app.setProgress unavailable — update FxTool' };
557
+ }
558
+ const time = app.setProgress(${S(extra?.progress ?? 0)});
559
+ return { success: true, action: 'set_progress', progress: ${S(extra?.progress ?? 0)}, time };
560
+ })();
561
+ `.trim();
562
+ case 'list_scrub_anchors':
563
+ return `
564
+ (function() {
565
+ if (typeof app.listScrubAnchors !== 'function') {
566
+ return { success: false, error: 'app.listScrubAnchors unavailable — update FxTool' };
567
+ }
568
+ const anchors = app.listScrubAnchors();
569
+ return { success: true, action: 'list_scrub_anchors', count: anchors.length, anchors };
570
+ })();
571
+ `.trim();
572
+ case 'bind_scroll': {
573
+ const scroll = extra?.scroll || {};
574
+ return `
575
+ // pp:InputDrivenPlayback — drive the timeline from scroll instead of a clock.
576
+ (function() {
577
+ if (typeof app.bindTimelineToScroll !== 'function') {
578
+ return { success: false, error: 'app.bindTimelineToScroll unavailable — update FxTool' };
579
+ }
580
+ const cfg = ${S(scroll)};
581
+ // The listener holds the app instance, so rebinding without unbinding leaks
582
+ // the old binding AND leaves two of them scrubbing the same timeline. Always
583
+ // release the previous one first.
584
+ let replaced = false;
585
+ if (typeof window !== 'undefined' && typeof window.${SCRUB_UNBIND_KEY} === 'function') {
586
+ try { window.${SCRUB_UNBIND_KEY}(); replaced = true; } catch (e) { /* already gone */ }
587
+ window.${SCRUB_UNBIND_KEY} = null;
588
+ }
589
+ const el = cfg.elementId && typeof document !== 'undefined'
590
+ ? document.getElementById(cfg.elementId)
591
+ : undefined;
592
+ if (cfg.elementId && !el) {
593
+ return { success: false, error: 'No element with id "' + cfg.elementId + '" — bind_scroll tracks a real DOM element' };
594
+ }
595
+ const unbind = app.bindTimelineToScroll({
596
+ ...(el ? { element: el } : {}),
597
+ ...(cfg.start !== undefined ? { start: cfg.start } : {}),
598
+ ...(cfg.end !== undefined ? { end: cfg.end } : {}),
599
+ ...(cfg.scrub !== undefined ? { scrub: cfg.scrub } : {}),
600
+ ...(cfg.range !== undefined ? { range: cfg.range } : {}),
601
+ });
602
+ if (typeof window !== 'undefined') window.${SCRUB_UNBIND_KEY} = unbind;
603
+ return { success: true, action: 'bind_scroll', bound: true, replaced, config: cfg };
604
+ })();
605
+ `.trim();
606
+ }
607
+ case 'unbind_scroll':
608
+ return `
609
+ (function() {
610
+ // Nothing bound is an answer, not an error — an agent tidying up should not
611
+ // have to know whether it bound anything.
612
+ if (typeof window === 'undefined' || typeof window.${SCRUB_UNBIND_KEY} !== 'function') {
613
+ return { success: true, action: 'unbind_scroll', bound: false };
614
+ }
615
+ window.${SCRUB_UNBIND_KEY}();
616
+ window.${SCRUB_UNBIND_KEY} = null;
617
+ return { success: true, action: 'unbind_scroll', bound: true };
618
+ })();
619
+ `.trim();
435
620
  case 'play':
436
621
  return `
437
622
  // Play keyframe timeline
@@ -744,6 +929,37 @@ const grid = app.createGrid({
744
929
  // =============================================================================
745
930
  // CODE GENERATOR CLASS
746
931
  // =============================================================================
932
+ /**
933
+ * Agent PLATFORM name → the engine's canvas-preset key.
934
+ *
935
+ * AgentPlatformSchema is an export-target vocabulary ('instagram', 'youtube')
936
+ * that was also being handed straight to app.setCanvasSize(), which keys on
937
+ * 'instagram-post' and 'full-hd-1080p'. Seven of the ten names matched nothing:
938
+ * the engine fell through to its default and resized the artboard to 800x600
939
+ * while recording the preset as applied, so an agent asking for an Instagram
940
+ * canvas silently got neither the size nor an error. (FxTool 496d3910 now
941
+ * refuses an unknown key instead — better, but it warns to a console the
942
+ * production build strips, so the agent still learns nothing.)
943
+ *
944
+ * Mapping rather than renaming: 'instagram' is the right word for an export
945
+ * TARGET and a poor one for a canvas SIZE, and only the second use was wrong.
946
+ */
947
+ const PLATFORM_TO_CANVAS_PRESET = {
948
+ instagram: 'instagram-post', // 1080x1080
949
+ 'instagram-story': 'instagram-story',
950
+ tiktok: 'tiktok',
951
+ youtube: 'full-hd-1080p', // the video frame, 1920x1080
952
+ 'youtube-thumbnail': 'youtube-thumbnail',
953
+ twitter: 'twitter-post',
954
+ linkedin: 'linkedin-post',
955
+ web: 'hd-720p', // 1280x720
956
+ 'print-a4': 'a4-portrait',
957
+ 'print-letter': 'letter-portrait',
958
+ };
959
+ /** The engine key for a platform, falling back to the value itself. */
960
+ export function canvasPresetFor(platform) {
961
+ return PLATFORM_TO_CANVAS_PRESET[platform] || platform;
962
+ }
747
963
  export class PinePaperCodeGenerator {
748
964
  /**
749
965
  * Generate code for creating an item
@@ -809,6 +1025,27 @@ export class PinePaperCodeGenerator {
809
1025
  const validated = KeyframeAnimateInputSchema.parse(input);
810
1026
  return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits);
811
1027
  }
1028
+ /**
1029
+ * Generate code for importing / retargeting a motion-capture clip
1030
+ */
1031
+ generateImportMotionCapture(input) {
1032
+ const v = ImportMotionCaptureInputSchema.parse(input);
1033
+ if (v.mode === 'retarget' && !v.skeletonId) {
1034
+ throw new Error("pinepaper_import_motion_capture: mode='retarget' requires skeletonId (the rig to drive)");
1035
+ }
1036
+ const opts = {};
1037
+ if (v.fps !== undefined)
1038
+ opts.fps = v.fps;
1039
+ if (v.height !== undefined)
1040
+ opts.height = v.height;
1041
+ if (v.position !== undefined)
1042
+ opts.position = v.position;
1043
+ if (v.name !== undefined)
1044
+ opts.name = v.name;
1045
+ if (v.boneMap !== undefined)
1046
+ opts.boneMap = v.boneMap;
1047
+ return generateImportMotionCaptureCode(v.bvh, v.mode, v.skeletonId, opts);
1048
+ }
812
1049
  /**
813
1050
  * Generate code for executing a generator
814
1051
  */
@@ -832,8 +1069,8 @@ export class PinePaperCodeGenerator {
832
1069
  /**
833
1070
  * Generate code for timeline control
834
1071
  */
835
- generatePlayTimeline(action, duration, loop, time, deterministic) {
836
- return generatePlayTimelineCode(action, duration, loop, time, deterministic);
1072
+ generatePlayTimeline(action, duration, loop, time, deterministic, extra) {
1073
+ return generatePlayTimelineCode(action, duration, loop, time, deterministic, extra);
837
1074
  }
838
1075
  /**
839
1076
  * Generate code for setting background color
@@ -1420,7 +1657,7 @@ app.historyManager.saveState();
1420
1657
  */
1421
1658
  generateConnect(input) {
1422
1659
  const validated = ConnectInputSchema.parse(input);
1423
- const { sourceItemId, targetItemId, routing, lineColor, lineWidth, lineStyle, headStyle, tailStyle, label, curvature, boltEnabled, boltColor, } = validated;
1660
+ const { id, sourceItemId, targetItemId, routing, lineColor, lineWidth, lineStyle, headStyle, tailStyle, label, curvature, boltEnabled, boltColor, } = validated;
1424
1661
  const config = {
1425
1662
  routing,
1426
1663
  lineStyle,
@@ -1430,6 +1667,13 @@ app.historyManager.saveState();
1430
1667
  boltEnabled,
1431
1668
  boltColor,
1432
1669
  };
1670
+ // A caller-supplied id is honoured by Connector (`config.id || <generated>`),
1671
+ // and it is the ONLY way a caller can address this connector afterwards:
1672
+ // update/remove take a connectorId, and the fallback the engine mints is
1673
+ // `connector_${Date.now()}_${random}` — unpredictable, and different on
1674
+ // every run of the same scene.
1675
+ if (id)
1676
+ config.id = id;
1433
1677
  if (lineColor)
1434
1678
  config.lineColor = lineColor;
1435
1679
  if (lineWidth)
@@ -1457,8 +1701,13 @@ app.historyManager.saveState();
1457
1701
  */
1458
1702
  generateConnectPorts(input) {
1459
1703
  const validated = ConnectPortsInputSchema.parse(input);
1460
- const { sourceItemId, sourcePort, targetItemId, targetPort, config } = validated;
1461
- const configStr = config ? JSON.stringify(config, null, 2) : '{}';
1704
+ const { id, sourceItemId, sourcePort, targetItemId, targetPort, config } = validated;
1705
+ // `id` sits at the TOP level of the tool's input but belongs inside the
1706
+ // engine's config object, which is where Connector reads it from. Merged
1707
+ // here rather than asking callers to nest it, since every other style
1708
+ // option they pass is nested and the id is not a style.
1709
+ const merged = id ? { ...(config ?? {}), id } : config;
1710
+ const configStr = merged ? JSON.stringify(merged, null, 2) : '{}';
1462
1711
  return `
1463
1712
  // Connect ports: ${sourceItemId}:${sourcePort} -> ${targetItemId}:${targetPort}
1464
1713
  const sourceItem = app.getItemById('${sourceItemId}');
@@ -1659,8 +1908,8 @@ throw new Error('Unknown diagram mode action: ${action}');
1659
1908
  }
1660
1909
  if (canvasPreset) {
1661
1910
  code += `
1662
- // Set canvas size to preset
1663
- app.setCanvasSize('${canvasPreset}');
1911
+ // Set canvas size to preset (platform name → engine preset key)
1912
+ app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
1664
1913
  // Allow canvas resize to take effect
1665
1914
  await new Promise(r => setTimeout(r, 50));
1666
1915
  `;
@@ -1866,8 +2115,8 @@ throw new Error('Unknown diagram mode action: ${action}');
1866
2115
  }
1867
2116
  if (canvasPreset) {
1868
2117
  code += `
1869
- // Set canvas to preset
1870
- app.setCanvasSize('${canvasPreset}');
2118
+ // Set canvas to preset (platform name → engine preset key)
2119
+ app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
1871
2120
  // Allow canvas resize to take effect
1872
2121
  await new Promise(r => setTimeout(r, 50));
1873
2122
  `;
@@ -2115,6 +2364,10 @@ const item = app.getItemById(targetId);
2115
2364
  if (!item) throw new Error('Item not found: ' + targetId);
2116
2365
  if (!app.applyAnimatedMask) throw new Error('Mask system not available');
2117
2366
  const maskedGroup = app.applyAnimatedMask(item, '${preset}', ${maskOpts});
2367
+ const maskRej = maskedGroup && maskedGroup.data && maskedGroup.data.maskAnimationRejected;
2368
+ if (maskRej) {
2369
+ return { success: false, itemId: targetId, error: 'Masked, but animation preset "' + maskRej.requested + '" is unknown — it will NOT animate.', requested: maskRej.requested, known: maskRej.known };
2370
+ }
2118
2371
  return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
2119
2372
  `;
2120
2373
  }
@@ -3618,6 +3871,18 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3618
3871
  } else {
3619
3872
  maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
3620
3873
  }
3874
+ // A mask with an unknown ANIMATION preset still masks — the item just never
3875
+ // animates — so !!maskedGroup is true either way. The engine records which.
3876
+ const rej = maskedGroup && maskedGroup.data && maskedGroup.data.maskAnimationRejected;
3877
+ if (rej) {
3878
+ return {
3879
+ success: false,
3880
+ itemId: '${validated.itemId}',
3881
+ error: 'Masked, but the animation preset "' + rej.requested + '" is unknown — it will NOT animate.',
3882
+ requested: rej.requested,
3883
+ known: rej.known,
3884
+ };
3885
+ }
3621
3886
  return { success: !!maskedGroup, itemId: '${validated.itemId}', preset: '${preset}' };
3622
3887
  } catch (error) {
3623
3888
  return { success: false, error: error.message };
@@ -4052,6 +4317,238 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
4052
4317
  return `(function() { return { error: 'Unknown scene_playback action: ${input.action}' }; })();`;
4053
4318
  }
4054
4319
  }
4320
+ generateSceneGraph(input) {
4321
+ const validated = SceneGraphInputSchema.parse(input);
4322
+ const S = (v) => JSON.stringify(v);
4323
+ if (validated.action === 'validate') {
4324
+ return `
4325
+ // Check a scene graph WITHOUT drawing it — the same check createSceneGraph runs,
4326
+ // so an author can fix the graph before committing to a canvas full of cards.
4327
+ (function() {
4328
+ if (typeof app.validateSceneGraph !== 'function') {
4329
+ return { success: false, error: 'app.validateSceneGraph unavailable — update FxTool' };
4330
+ }
4331
+ const r = app.validateSceneGraph(${S(validated.graph)});
4332
+ return {
4333
+ success: true,
4334
+ action: 'validate',
4335
+ ok: r ? r.ok : false,
4336
+ errors: (r && r.errors) || [],
4337
+ // Cycles are legitimate (a retry loop) but reported: an accidental one
4338
+ // looks exactly like a game that never ends.
4339
+ warnings: (r && r.warnings) || [],
4340
+ reachable: (r && r.reachable) || [],
4341
+ cycles: (r && r.cycles) || [],
4342
+ };
4343
+ })();
4344
+ `.trim();
4345
+ }
4346
+ return `
4347
+ // Create interactive scene graph (story / quiz cards)
4348
+ (async function() {
4349
+ if (typeof app.createSceneGraph !== 'function') {
4350
+ return { success: false, error: 'app.createSceneGraph unavailable — update FxTool' };
4351
+ }
4352
+ const result = await app.createSceneGraph(${S(validated.graph)}, ${S(validated.opts || {})});
4353
+ if (!result) return { success: false, error: 'No result returned' };
4354
+ return {
4355
+ success: result.ok !== false,
4356
+ ok: result.ok,
4357
+ errors: result.errors || [],
4358
+ warnings: result.warnings || [],
4359
+ // The engine reports these and this tool used to drop them. \`failed\` is the
4360
+ // one that matters: a graph can compile, render, and still leave N click
4361
+ // relations unwired — it looks built and is inert, and nothing else in the
4362
+ // result says so. A refused plan returns early without them, hence the
4363
+ // defaults.
4364
+ wired: result.wired,
4365
+ failed: result.failed,
4366
+ cycles: result.cycles || [],
4367
+ entranced: result.entranced,
4368
+ start: result.start,
4369
+ ids: result.ids || {},
4370
+ };
4371
+ })();
4372
+ `.trim();
4373
+ }
4374
+ // ===========================================================================
4375
+ // RELATIVE TIMING, STAGGERS, FLIP
4376
+ // ===========================================================================
4377
+ generateSequence(input) {
4378
+ const validated = SequenceInputSchema.parse(input);
4379
+ const S = (v) => JSON.stringify(v);
4380
+ if (validated.action === 'list_forms') {
4381
+ return `
4382
+ (function() {
4383
+ if (typeof app.listPositionForms !== 'function') {
4384
+ return { success: false, error: 'app.listPositionForms unavailable — update FxTool' };
4385
+ }
4386
+ const forms = app.listPositionForms();
4387
+ return { success: true, action: 'list_forms', count: forms.length, forms };
4388
+ })();
4389
+ `.trim();
4390
+ }
4391
+ if (validated.action === 'resolve') {
4392
+ return `
4393
+ // Resolve ONE relative position to absolute seconds.
4394
+ (function() {
4395
+ if (typeof app.resolveTimelinePosition !== 'function') {
4396
+ return { success: false, error: 'app.resolveTimelinePosition unavailable — update FxTool' };
4397
+ }
4398
+ const ctx = { ...${S(validated.context || {})}, labels: ${S(validated.labels || {})} };
4399
+ const seconds = app.resolveTimelinePosition(${S(validated.position ?? null)}, ctx);
4400
+ return { success: true, action: 'resolve', position: ${S(validated.position ?? null)}, seconds, context: ctx };
4401
+ })();
4402
+ `.trim();
4403
+ }
4404
+ return `
4405
+ // Place a run of clips in order, each resolved against the ones before it.
4406
+ (function() {
4407
+ if (typeof app.placeSequence !== 'function') {
4408
+ return { success: false, error: 'app.placeSequence unavailable — update FxTool' };
4409
+ }
4410
+ const clips = ${S(validated.clips || [])};
4411
+ const result = app.placeSequence(clips, {
4412
+ startAt: ${S(validated.startAt ?? 0)},
4413
+ labels: ${S(validated.labels || {})},
4414
+ });
4415
+ // Echo the caller's own clip ids back onto the placements — placeSequence
4416
+ // answers positionally, and an agent that named its clips should get the
4417
+ // names back rather than having to re-zip two arrays by index.
4418
+ const placed = (result.placed || []).map((p, i) => (
4419
+ clips[i] && clips[i].id ? { id: clips[i].id, ...p } : p
4420
+ ));
4421
+ return {
4422
+ success: true,
4423
+ action: 'place',
4424
+ placed,
4425
+ labels: result.labels,
4426
+ // The timeline only ever grows: a clip placed early with "-=" must not pull
4427
+ // the end back and silently truncate everything after it.
4428
+ duration: result.duration,
4429
+ };
4430
+ })();
4431
+ `.trim();
4432
+ }
4433
+ generateStagger(input) {
4434
+ const validated = StaggerInputSchema.parse(input);
4435
+ const S = (v) => JSON.stringify(v);
4436
+ if (validated.action === 'list_origins') {
4437
+ return `
4438
+ (function() {
4439
+ if (typeof app.listStaggerOrigins !== 'function') {
4440
+ return { success: false, error: 'app.listStaggerOrigins unavailable — update FxTool' };
4441
+ }
4442
+ const origins = app.listStaggerOrigins();
4443
+ return { success: true, action: 'list_origins', count: origins.length, origins };
4444
+ })();
4445
+ `.trim();
4446
+ }
4447
+ if (validated.action === 'preview') {
4448
+ return `
4449
+ // Delays for a count, without touching the canvas — plan before committing.
4450
+ (function() {
4451
+ if (typeof app.staggerDelays !== 'function') {
4452
+ return { success: false, error: 'app.staggerDelays unavailable — update FxTool' };
4453
+ }
4454
+ const delays = app.staggerDelays(${S(validated.count ?? 0)}, ${S(validated.opts || {})});
4455
+ return {
4456
+ success: true,
4457
+ action: 'preview',
4458
+ count: ${S(validated.count ?? 0)},
4459
+ delays,
4460
+ span: delays.length ? Math.max(...delays) : 0,
4461
+ };
4462
+ })();
4463
+ `.trim();
4464
+ }
4465
+ return `
4466
+ // Apply a stagger to real items, in the order given.
4467
+ (function() {
4468
+ if (typeof app.staggerItems !== 'function') {
4469
+ return { success: false, error: 'app.staggerItems unavailable — update FxTool' };
4470
+ }
4471
+ const ids = ${S(validated.itemIds || [])};
4472
+ if (!ids.length) {
4473
+ return { success: false, error: 'apply needs itemIds, in the order the stagger should follow (row-major for a grid)' };
4474
+ }
4475
+ const r = app.staggerItems(ids, ${S(validated.opts || {})});
4476
+ return {
4477
+ success: r && r.ok === true,
4478
+ action: 'apply',
4479
+ // 'applied' can be lower than the ids given — an id that resolves to
4480
+ // nothing is skipped, and a stagger that silently covered fewer items than
4481
+ // asked for is the failure worth seeing.
4482
+ requested: ids.length,
4483
+ applied: r ? r.applied : 0,
4484
+ delays: r ? r.delays : [],
4485
+ span: r ? r.span : 0,
4486
+ };
4487
+ })();
4488
+ `.trim();
4489
+ }
4490
+ generateFlip(input) {
4491
+ const validated = FlipInputSchema.parse(input);
4492
+ const S = (v) => JSON.stringify(v);
4493
+ if (validated.action === 'record') {
4494
+ return `
4495
+ // pp:Flip step one — record where things are NOW, before the change.
4496
+ //
4497
+ // The record stays ON THE PAGE rather than travelling back through the tool
4498
+ // result: it is one snapshot per item of raw geometry, useless to read and
4499
+ // expensive to carry, and \`apply\` is the only thing that ever wants it.
4500
+ (function() {
4501
+ if (typeof app.recordFlipState !== 'function') {
4502
+ return { success: false, error: 'app.recordFlipState unavailable — update FxTool' };
4503
+ }
4504
+ const ids = ${S(validated.itemIds || null)};
4505
+ const record = ids && ids.length ? app.recordFlipState(ids) : app.recordFlipState();
4506
+ if (typeof window !== 'undefined') window.__ppFlipRecord = record;
4507
+ return {
4508
+ success: true,
4509
+ action: 'record',
4510
+ recorded: (record.snapshots || []).length,
4511
+ takenAt: record.takenAt,
4512
+ ids: (record.snapshots || []).map(s => s.id),
4513
+ };
4514
+ })();
4515
+ `.trim();
4516
+ }
4517
+ const opts = {};
4518
+ if (validated.duration !== undefined)
4519
+ opts.duration = validated.duration;
4520
+ if (validated.easing !== undefined)
4521
+ opts.easing = validated.easing;
4522
+ if (validated.enter !== undefined)
4523
+ opts.enter = validated.enter;
4524
+ if (validated.stagger !== undefined)
4525
+ opts.stagger = validated.stagger;
4526
+ return `
4527
+ // pp:Flip step two — animate from the recorded state to wherever things are now.
4528
+ // Writes ordinary keyframes, so the transition scrubs, exports to SMIL and
4529
+ // Lottie, and survives a reload.
4530
+ (function() {
4531
+ if (typeof app.flipItems !== 'function') {
4532
+ return { success: false, error: 'app.flipItems unavailable — update FxTool' };
4533
+ }
4534
+ const record = typeof window !== 'undefined' ? window.__ppFlipRecord : null;
4535
+ if (!record || !(record.snapshots || []).length) {
4536
+ return { success: false, error: 'Nothing was recorded — run pinepaper_flip { action: "record" } BEFORE the change, then rearrange, then apply' };
4537
+ }
4538
+ const r = app.flipItems(record, ${S(opts)});
4539
+ return {
4540
+ success: r && r.ok === true,
4541
+ action: 'apply',
4542
+ moved: r ? r.moved : 0,
4543
+ entered: r ? r.entered : [],
4544
+ // Items that LEFT during the change cannot be animated out — they are gone
4545
+ // already. Remove them after the flip, not before.
4546
+ left: r ? r.left : [],
4547
+ duration: r ? r.duration : 0,
4548
+ };
4549
+ })();
4550
+ `.trim();
4551
+ }
4055
4552
  // ===========================================================================
4056
4553
  // SELECTION, TRANSFORM & HISTORY
4057
4554
  // ===========================================================================
@@ -4261,6 +4758,14 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
4261
4758
  ...(input.assets ? { assets: input.assets } : {}),
4262
4759
  ...(input.audio ? { audio: input.audio } : {}),
4263
4760
  ...(input.grid ? { grid: true } : {}),
4761
+ // The design axes. A register resolves the craft (gutter, margin,
4762
+ // hue budget, type scale); a medium says what makes the marks and
4763
+ // is REFUSED when a composition cannot be rendered in it.
4764
+ ...(input.register ? { register: input.register } : {}),
4765
+ ...(input.level !== undefined ? { level: input.level } : {}),
4766
+ ...(input.medium ? { medium: input.medium } : {}),
4767
+ ...(input.stitch ? { stitch: input.stitch } : {}),
4768
+ ...(input.stitchBudget !== undefined ? { stitchBudget: input.stitchBudget } : {}),
4264
4769
  })}`, 'Compose: apply pattern');
4265
4770
  }
4266
4771
  }
@@ -4520,6 +5025,12 @@ ${resolve}
4520
5025
  ${guard}
4521
5026
  try {
4522
5027
  const result = await app.imageTools.applyCutoutStyle(${JSON.stringify(input.itemId || '')}, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
5028
+ // An unknown preset returns the very item it was given, unchanged — so a
5029
+ // truthy result proves nothing. The engine records the refusal on it.
5030
+ const cutRej = result && result.data && result.data.cutoutStyleRejected;
5031
+ if (cutRej) {
5032
+ return { success: false, action: 'apply', itemId: ${JSON.stringify(input.itemId || '')}, error: 'Unknown cutout preset "' + cutRej.requested + '" — the item is UNCHANGED.', requested: cutRej.requested, known: cutRej.known };
5033
+ }
4523
5034
  return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId || '')}, preset: ${JSON.stringify(input.preset || '')} };
4524
5035
  } catch (e) {
4525
5036
  return { error: 'Failed to apply cutout style: ' + e.message };
@@ -4687,6 +5198,144 @@ ${resolve}
4687
5198
  return `(function() { return { error: 'Unknown query action: ${input.action}' }; })();`;
4688
5199
  }
4689
5200
  }
5201
+ generateQueryCapabilities(input) {
5202
+ const validated = QueryCapabilitiesInputSchema.parse(input);
5203
+ const S = (v) => JSON.stringify(v);
5204
+ // Warming is the engine's default and the honest answer, so the argument is
5205
+ // omitted unless the caller explicitly opted out — no reason to spend bytes
5206
+ // restating a default.
5207
+ const warmArg = validated.warm === undefined ? '' : S({ warm: validated.warm });
5208
+ return `
5209
+ // Query capabilities / recommend treatments from the engine's LIVE registries.
5210
+ //
5211
+ // Reads app.getCapabilities(), which warms the lazily-constructed registries
5212
+ // before answering and returns a coverage report saying what it read from.
5213
+ // \`warm: false\` skips that and answers from whatever is already resident —
5214
+ // cheaper, and the coverage report labels itself \`warmed: false\` when it does.
5215
+ // This tool previously rebuilt that index itself, and because generatorRegistry
5216
+ // is null until ensureHeavyModules() lands (~1.2s after boot) and the rigging /
5217
+ // blending / deform relation rules only register when their subsystem is first
5218
+ // touched, an early call reported ZERO generators and ~77 of ~100 relations as
5219
+ // if that were the whole engine.
5220
+ (async function() {
5221
+ const action = ${S(validated.action || 'list')};
5222
+ const kind = ${S(validated.kind)};
5223
+ const mood = ${S(validated.mood)};
5224
+ const subject = ${S(validated.subject)};
5225
+ const avoid = ${S(validated.avoid)};
5226
+ const seed = ${S(validated.seed)};
5227
+ const key = ${S(validated.key)};
5228
+ const exclude = ${S(validated.exclude)};
5229
+
5230
+ if (typeof app.getCapabilities !== 'function') {
5231
+ return { success: false, error: 'app.getCapabilities unavailable — update FxTool' };
5232
+ }
5233
+
5234
+ let caps = [], report = null;
5235
+ try {
5236
+ const res = await app.getCapabilities(${warmArg});
5237
+ caps = (res && res.capabilities) || [];
5238
+ report = (res && res.coverage) || null;
5239
+ } catch (e) {
5240
+ return { success: false, error: 'Failed to inspect capabilities: ' + e.message };
5241
+ }
5242
+
5243
+ if (action === 'coverage') {
5244
+ // The engine's own report: totals per kind, the entries that can be applied
5245
+ // but not RANKED (no label, no definition), the kinds with no source wired,
5246
+ // and which lazy registries were live when it answered.
5247
+ return { success: true, action: 'coverage', ...(report || {}), total: caps.length };
5248
+ }
5249
+
5250
+ if (action === 'find') {
5251
+ if (!key) return { success: false, error: 'find action requires key' };
5252
+ const found = caps.find(c => c.key === String(key));
5253
+ return found ? { success: true, action: 'find', capability: found } : { success: false, error: 'Capability not found: ' + key };
5254
+ }
5255
+
5256
+ let filtered = caps;
5257
+ if (kind) {
5258
+ const kinds = new Set(Array.isArray(kind) ? kind : [kind]);
5259
+ filtered = caps.filter(c => kinds.has(c.kind));
5260
+ }
5261
+
5262
+ if (action === 'list') {
5263
+ return {
5264
+ success: true,
5265
+ action: 'list',
5266
+ count: filtered.length,
5267
+ total: caps.length,
5268
+ unwiredKinds: report ? report.unwiredKinds : undefined,
5269
+ capabilities: filtered.map(c => ({
5270
+ key: c.key, kind: c.kind, label: c.label,
5271
+ definition: c.definition, applyWith: c.applyWith, describable: c.describable
5272
+ }))
5273
+ };
5274
+ }
5275
+
5276
+ if (action === 'choose') {
5277
+ const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'into', 'at', 'on', 'as', 'is', 'are', 'then', 'their', 'they', 'it', 'its', 'each', 'from', 'with', 'that', 'characters', 'character', 'letters', 'letter', 'text', 'frame', 'real']);
5278
+ const tokenize = (t) => String(t || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 2 && !STOP.has(w));
5279
+ const MOOD_TERMS = {
5280
+ reveal: ['resolve', 'resolves', 'reveal', 'appear', 'emerge', 'decode', 'decrypt', 'unscramble', 'form', 'assemble', 'converge'],
5281
+ triumphant: ['burst', 'launch', 'firework', 'bloom', 'spark', 'explode', 'shine', 'glow', 'celebrate', 'rise'],
5282
+ failure: ['crumble', 'fall', 'collapse', 'burn', 'decay', 'dissolve', 'break', 'shatter', 'sink', 'error', 'corrupt'],
5283
+ calm: ['drift', 'float', 'gentle', 'slow', 'settle', 'fade', 'wave', 'ripple', 'pour'],
5284
+ technical: ['binary', 'matrix', 'grid', 'scan', 'code', 'digital', 'circuit', 'data', 'print', 'terminal'],
5285
+ energetic: ['bounce', 'swarm', 'spray', 'shoot', 'fast', 'rapid', 'scatter', 'slam', 'volley']
5286
+ };
5287
+
5288
+ // Deterministic tiebreak. Equal-scoring candidates used to fall out in
5289
+ // array order, which made \`seed\` — documented as giving reproducible
5290
+ // selection — do nothing at all. FNV-1a over seed+key gives a stable
5291
+ // shuffle per seed; with no seed the order is stable by key, so a repeated
5292
+ // call still answers the same way.
5293
+ const tiebreak = (k) => {
5294
+ if (!seed) return null;
5295
+ let h = 2166136261;
5296
+ const s = String(seed) + '|' + k;
5297
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
5298
+ return h >>> 0;
5299
+ };
5300
+
5301
+ const exSet = new Set(Array.isArray(exclude) ? exclude : []);
5302
+ const scored = filtered.filter(c => !exSet.has(c.key)).map(c => {
5303
+ const words = new Set(tokenize(c.text || (c.label + ' ' + c.definition)));
5304
+ if (!words.size) return { item: c, score: 0 };
5305
+ let score = 0;
5306
+ const moods = Array.isArray(mood) ? mood : (mood ? [mood] : []);
5307
+ for (const m of moods) {
5308
+ for (const term of (MOOD_TERMS[m] || [])) { if (words.has(term)) score += 12; }
5309
+ }
5310
+ for (const w of tokenize(subject)) { if (words.has(w)) score += 7; }
5311
+ for (const av of (Array.isArray(avoid) ? avoid : (avoid ? [avoid] : []))) {
5312
+ for (const term of (MOOD_TERMS[av] || [])) { if (words.has(term)) score -= 10; }
5313
+ }
5314
+ return { item: c, score };
5315
+ });
5316
+
5317
+ scored.sort((a, b) => {
5318
+ if (b.score !== a.score) return b.score - a.score;
5319
+ const ha = tiebreak(a.item.key), hb = tiebreak(b.item.key);
5320
+ if (ha !== null && ha !== hb) return ha - hb;
5321
+ return a.item.key.localeCompare(b.item.key);
5322
+ });
5323
+ const top = scored.slice(0, 5).map(s => ({ ...s.item, score: s.score }));
5324
+ return {
5325
+ success: true,
5326
+ action: 'choose',
5327
+ recommendation: top[0] || null,
5328
+ topCandidates: top,
5329
+ // A chooser scores on description, so entries without one can never be
5330
+ // recommended. Saying so beats silently never offering them.
5331
+ unrankable: report ? report.unrankable : undefined
5332
+ };
5333
+ }
5334
+
5335
+ return { success: false, error: 'Unknown capabilities action: ' + action };
5336
+ })();
5337
+ `.trim();
5338
+ }
4690
5339
  // ===========================================================================
4691
5340
  // DEFORMATION
4692
5341
  // ===========================================================================
@@ -5709,6 +6358,178 @@ ${guard}
5709
6358
  palettes: typeof app.listTextPalettes === 'function' ? app.listTextPalettes() : [],
5710
6359
  fontAxes: typeof app.listFontAxes === 'function' ? app.listFontAxes() : null,
5711
6360
  };
6361
+ })();`.trim();
6362
+ }
6363
+ }
6364
+ }
6365
+ /**
6366
+ * pinepaper_text_effect — the 37 character-level text effects.
6367
+ *
6368
+ * `applyTextEffect` is ASYNC (it lazy-`import()`s the effects chunk), so this
6369
+ * emits an async IIFE — a sync wrapper would hand the bridge a pending
6370
+ * Promise instead of the result.
6371
+ *
6372
+ * The returned `items` are live Paper objects and cannot cross the bridge;
6373
+ * only ids/count/duration are forwarded. `ids` is the whole handle set the
6374
+ * caller now has, since the source item is gone.
6375
+ */
6376
+ /**
6377
+ * The medium axis. `list_media` and `resolve` are READS — they exist so an
6378
+ * agent can find out what this build can honestly make before it promises a
6379
+ * medium in prose, which is the failure the axis was built to prevent.
6380
+ */
6381
+ generateDesignMedium(input) {
6382
+ const S = (v) => JSON.stringify(v);
6383
+ const wrap = (comment, body) => `\n// ${comment}\n(function() {\n${body}\n})();`.trim();
6384
+ switch (input.action) {
6385
+ case 'list_media':
6386
+ return wrap('Medium: list', ` if (typeof app.listDesignMedia !== 'function') { return { success: false, error: 'medium axis unavailable — update FxTool' }; }
6387
+ const media = app.listDesignMedia();
6388
+ return { success: true, action: 'list_media', media: media, native: media.filter(function(m) { return m.fidelity === 'native'; }).map(function(m) { return m.key; }) };`);
6389
+ case 'resolve':
6390
+ return wrap('Medium: resolve', ` if (typeof app.resolveDesignMedium !== 'function') { return { success: false, error: 'medium axis unavailable — update FxTool' }; }
6391
+ const v = app.resolveDesignMedium(${S(input.medium)});
6392
+ return Object.assign({ success: true, action: 'resolve' }, v);`);
6393
+ case 'list_stitches':
6394
+ return wrap('Medium: list stitches', ` if (typeof app.listStitches !== 'function') { return { success: false, error: 'thread medium unavailable — update FxTool' }; }
6395
+ const stitches = app.listStitches();
6396
+ return { success: true, action: 'list_stitches', stitches: stitches, count: stitches.length };`);
6397
+ case 'apply_thread': {
6398
+ const opts = S({
6399
+ ...(input.stitch !== undefined ? { stitch: input.stitch } : {}),
6400
+ ...(input.field !== undefined ? { field: input.field } : {}),
6401
+ ...(input.stitchLen !== undefined ? { stitchLen: input.stitchLen } : {}),
6402
+ ...(input.rowGap !== undefined ? { rowGap: input.rowGap } : {}),
6403
+ ...(input.variance !== undefined ? { variance: input.variance } : {}),
6404
+ ...(input.width !== undefined ? { width: input.width } : {}),
6405
+ ...(input.sheen !== undefined ? { sheen: input.sheen } : {}),
6406
+ ...(input.color !== undefined ? { color: input.color } : {}),
6407
+ ...(input.seed !== undefined ? { seed: input.seed } : {}),
6408
+ ...(input.count !== undefined ? { count: input.count } : {}),
6409
+ });
6410
+ return wrap('Medium: render in thread', ` if (typeof app.applyThreadPainting !== 'function') { return { success: false, error: 'thread medium unavailable — update FxTool' }; }
6411
+ const entry = app.itemRegistry && app.itemRegistry.get(${S(input.itemId)});
6412
+ const src = entry && entry.item;
6413
+ if (!src) { return { success: false, action: 'apply_thread', error: 'no item ' + ${S(input.itemId)} }; }
6414
+ // Check the shape BEFORE calling, so the refusal can name the fix. The
6415
+ // engine warns to a console production strips, which an agent never sees.
6416
+ if (src.content !== undefined && typeof src.getPointAt !== 'function') {
6417
+ return { success: false, action: 'apply_thread', error: 'a text item has no outline to stitch. Convert it to glyph paths first (pinepaper_text_style), then stitch the result — the collage it produces is a group of closed paths, which this accepts.' };
6418
+ }
6419
+ const g = app.applyThreadPainting(${S(input.itemId)}, ${opts});
6420
+ if (!g) { return { success: false, action: 'apply_thread', error: 'nothing stitchable here — thread needs a CLOSED path, a compound path, or a group containing them. An open stroke, a raster and an empty group all land here. (If you passed a stitch name, check it against list_stitches.)' }; }
6421
+ return { success: true, action: 'apply_thread', groupId: (g.data && g.data.id) || null, stitches: g.children.length };`);
6422
+ }
6423
+ case 'list_flow_fields':
6424
+ return wrap('Medium: list flow fields', ` if (typeof app.listFlowFields !== 'function') { return { success: false, error: 'hatch medium unavailable — update FxTool' }; }
6425
+ const fields = app.listFlowFields();
6426
+ return { success: true, action: 'list_flow_fields', fields: fields, count: fields.length };`);
6427
+ case 'list_hatch_options':
6428
+ return wrap('Medium: hatch defaults', ` if (typeof app.listHatchOptions !== 'function') { return { success: false, error: 'hatch medium unavailable — update FxTool' }; }
6429
+ return { success: true, action: 'list_hatch_options', defaults: app.listHatchOptions() };`);
6430
+ case 'apply_hatch': {
6431
+ const opts = S({
6432
+ ...(input.distance !== undefined ? { distance: input.distance } : {}),
6433
+ ...(input.angle !== undefined ? { angle: input.angle } : {}),
6434
+ ...(input.gradient !== undefined ? { gradient: input.gradient } : {}),
6435
+ ...(input.rand !== undefined ? { rand: input.rand } : {}),
6436
+ ...(input.continuous !== undefined ? { continuous: input.continuous } : {}),
6437
+ ...(input.flowField !== undefined ? { field: input.flowField } : {}),
6438
+ ...(input.t !== undefined ? { t: input.t } : {}),
6439
+ ...(input.width !== undefined ? { width: input.width } : {}),
6440
+ ...(input.color !== undefined ? { color: input.color } : {}),
6441
+ ...(input.seed !== undefined ? { seed: input.seed } : {}),
6442
+ });
6443
+ return wrap('Medium: rule an item with hatching', ` if (typeof app.applyHatching !== 'function') { return { success: false, error: 'hatch medium unavailable — update FxTool' }; }
6444
+ const entry = app.itemRegistry && app.itemRegistry.get(${S(input.itemId)});
6445
+ const src = entry && entry.item;
6446
+ if (!src) { return { success: false, action: 'apply_hatch', error: 'no item ' + ${S(input.itemId)} }; }
6447
+ // Every refusal below is one the engine only console.warns, and production
6448
+ // strips the console — so an agent would otherwise get a bare null and no
6449
+ // idea which of five different things went wrong.
6450
+ if (src.content !== undefined && typeof src.getPointAt !== 'function') {
6451
+ return { success: false, action: 'apply_hatch', error: 'a text item has no outline to hatch. Convert it to glyph paths first (pinepaper_text_style), then hatch the result — the collage it produces is a group of closed paths, which this accepts.' };
6452
+ }
6453
+ // Count the closed paths BEFORE the call, so a truncated group can be
6454
+ // reported as truncated rather than looking complete.
6455
+ let closed = 0;
6456
+ const isGroup = src.children && !(typeof src.getPointAt === 'function' && src.closed);
6457
+ if (isGroup) {
6458
+ const walk = function(n) {
6459
+ for (const c of (n.children || [])) {
6460
+ if (c.children && typeof c.getPointAt !== 'function') walk(c);
6461
+ else if (typeof c.getPointAt === 'function' && c.closed && c.length > 0) closed++;
6462
+ }
6463
+ };
6464
+ walk(src);
6465
+ if (!closed) { return { success: false, action: 'apply_hatch', error: 'that group contains no closed paths to hatch — hatching needs a closed outline, and open strokes, rasters and empty groups all land here.' }; }
6466
+ }
6467
+ const b = src.bounds;
6468
+ const g = app.applyHatching(${S(input.itemId)}, ${opts});
6469
+ if (!g) {
6470
+ // Spacing wider than the shape is the usual cause and is otherwise a
6471
+ // silent no-op: the item just vanishes behind an empty group.
6472
+ return { success: false, action: 'apply_hatch', error: 'nothing was ruled. Most often the spacing exceeds the shape — distance is ' + ${S(input.distance ?? 6)} + 'px against bounds ' + Math.round(b.width) + 'x' + Math.round(b.height) + '. Otherwise the item has no closed outline: hatching needs a closed path, a compound path, or a group of them.' };
6473
+ }
6474
+ const parts = (g.data && g.data.parts) || null;
6475
+ return {
6476
+ success: true,
6477
+ action: 'apply_hatch',
6478
+ groupId: (g.data && g.data.id) || null,
6479
+ lines: g.children ? g.children.length : 0,
6480
+ angle: (g.data && g.data.angle) !== undefined ? g.data.angle : null,
6481
+ spacing: (g.data && g.data.spacing) !== undefined ? g.data.spacing : null,
6482
+ // The engine hatches at most 40 paths from a group and warns about the rest.
6483
+ ...(parts !== null ? { parts: parts } : {}),
6484
+ ...(closed > 40 ? { truncated: 'hatched the first 40 of ' + closed + ' closed paths' } : {}),
6485
+ // Re-hatching REPLACES: a second set of lines over the first is
6486
+ // indistinguishable from nothing having happened, so trying spacings in
6487
+ // turn is safe and is the flow this exists for.
6488
+ note: 'the source item is now hidden behind the hatching; re-hatching replaces rather than stacks',
6489
+ };`);
6490
+ }
6491
+ default:
6492
+ return wrap('Medium: unknown action', ` return { success: false, error: 'unknown action' };`);
6493
+ }
6494
+ }
6495
+ generateTextEffect(input) {
6496
+ const S = (v) => JSON.stringify(v);
6497
+ switch (input.action) {
6498
+ case 'apply': {
6499
+ // options spreads FIRST so the named fields win on collision — the
6500
+ // schema advertises that precedence, so it has to be real here.
6501
+ const opts = S({
6502
+ ...(input.options ?? {}),
6503
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
6504
+ ...(input.seed !== undefined ? { seed: input.seed } : {}),
6505
+ ...(input.gradient !== undefined ? { gradient: input.gradient } : {}),
6506
+ ...(input.gradientStops !== undefined ? { gradientStops: input.gradientStops } : {}),
6507
+ ...(input.gradientDirection !== undefined ? { gradientDirection: input.gradientDirection } : {}),
6508
+ ...(input.gradientSteps !== undefined ? { gradientSteps: input.gradientSteps } : {}),
6509
+ ...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}),
6510
+ });
6511
+ return `
6512
+ // Text effect: ${input.effect} — the text becomes one animated item PER CHARACTER
6513
+ (async function() {
6514
+ if (typeof app.applyTextEffect !== 'function') { return { success: false, error: 'app.applyTextEffect unavailable — update FxTool to a text-effects build' }; }
6515
+ const r = await app.applyTextEffect(${S(input.itemId)}, ${S(input.effect)}, ${opts});
6516
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'text effect failed' }; }
6517
+ // r.items are live Paper objects — NOT serializable, deliberately dropped.
6518
+ // The source item id is GONE unless keepSource was set, so r.ids is now the
6519
+ // caller's entire handle set; returning it is what makes the tool usable.
6520
+ return {
6521
+ success: true, action: 'apply', effect: r.effect, ids: r.ids, count: r.count,
6522
+ duration: r.duration, sourceRemoved: ${S(input.keepSource !== true)},
6523
+ };
6524
+ })();`.trim();
6525
+ }
6526
+ case 'list': {
6527
+ return `
6528
+ // The 37 character-level text effects — the picker surface
6529
+ (async function() {
6530
+ if (typeof app.listTextEffects !== 'function') { return { success: false, error: 'app.listTextEffects unavailable — update FxTool to a text-effects build' }; }
6531
+ const effects = await app.listTextEffects();
6532
+ return { success: true, effects, count: effects.length };
5712
6533
  })();`.trim();
5713
6534
  }
5714
6535
  }
@@ -5966,7 +6787,8 @@ ${needWorld}
5966
6787
  const root = input.rootPosition ? S(input.rootPosition) : S({ x: 400, y: 300 });
5967
6788
  return wrap('Rigging: create skeleton', ` const skeletonId = R.createSkeleton(${S(input.name ?? null)}, ${root});
5968
6789
  if (app.historyManager) app.historyManager.saveState();
5969
- return { success: !!skeletonId, action: 'create_skeleton', skeletonId: skeletonId };`);
6790
+ if (!skeletonId) { return { success: false, action: 'create_skeleton', error: "the engine returned no skeleton id — createSkeleton does not normally fail, so treat this as a broken rigging subsystem rather than bad input" }; }
6791
+ return { success: true, action: 'create_skeleton', skeletonId: skeletonId };`);
5970
6792
  }
5971
6793
  case 'add_bone': {
5972
6794
  const config = S({
@@ -5979,13 +6801,15 @@ ${needWorld}
5979
6801
  });
5980
6802
  return wrap('Rigging: add bone', ` const boneId = R.addBone(${S(input.skeletonId)}, ${config});
5981
6803
  if (app.historyManager) app.historyManager.saveState();
5982
- return { success: !!boneId, action: 'add_bone', boneId: boneId };`);
6804
+ if (!boneId) { return { success: false, action: 'add_bone', error: "no such skeleton. Call list_skeletons for the ids that exist" }; }
6805
+ return { success: true, action: 'add_bone', boneId: boneId };`);
5983
6806
  }
5984
6807
  case 'attach_item': {
5985
6808
  const opts = S(input.attachPoint !== undefined ? { attachPoint: input.attachPoint } : {});
5986
6809
  return wrap('Rigging: attach item to bone', ` const ok = R.attachItem(${S(input.skeletonId)}, ${S(input.boneId)}, ${S(input.itemId)}, ${opts});
5987
6810
  if (app.historyManager) app.historyManager.saveState();
5988
- return { success: !!ok, action: 'attach_item', itemId: ${S(input.itemId)}, boneId: ${S(input.boneId)} };`);
6811
+ if (!ok) { return { success: false, action: 'attach_item', error: "no such skeleton, or no such bone on it. list_bones gives the bone ids for a skeleton" }; }
6812
+ return { success: true, action: 'attach_item', itemId: ${S(input.itemId)}, boneId: ${S(input.boneId)} };`);
5989
6813
  }
5990
6814
  case 'create_ik_chain': {
5991
6815
  const config = S({
@@ -5999,7 +6823,8 @@ ${needWorld}
5999
6823
  });
6000
6824
  return wrap('Rigging: create IK chain', ` const chainId = R.createIKChain(${S(input.skeletonId)}, ${config});
6001
6825
  if (app.historyManager) app.historyManager.saveState();
6002
- return { success: !!chainId, action: 'create_ik_chain', chainId: chainId };`);
6826
+ if (!chainId) { return { success: false, action: 'create_ik_chain', error: "no such skeleton, or fewer than 2 boneIds — a chain needs at least two bones to solve. Call list_bones for the ids on this rig" }; }
6827
+ return { success: true, action: 'create_ik_chain', chainId: chainId };`);
6003
6828
  }
6004
6829
  case 'add_pose_keyframe': {
6005
6830
  const opts = S({
@@ -6012,7 +6837,8 @@ ${needWorld}
6012
6837
  });
6013
6838
  return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${S(input.pose)}, ${S(input.easing ?? 'linear')}, ${opts});
6014
6839
  if (app.historyManager) app.historyManager.saveState();
6015
- return { success: !!ok, action: 'add_pose_keyframe', time: ${input.time} };`);
6840
+ if (!ok) { return { success: false, action: 'add_pose_keyframe', error: "no such skeleton, or 'pose' named a saved pose that does not exist. Pass an inline { boneId: angleDeg } map instead, or list_poses for the saved ones" }; }
6841
+ return { success: true, action: 'add_pose_keyframe', time: ${input.time} };`);
6016
6842
  }
6017
6843
  case 'set_target_path': {
6018
6844
  const opts = S({
@@ -6021,16 +6847,170 @@ ${needWorld}
6021
6847
  });
6022
6848
  return wrap('Rigging: set IK target path', ` const ok = R.setTargetPath(${S(input.skeletonId)}, ${S(input.chainId)}, ${S(input.waypoints)}, ${opts});
6023
6849
  if (app.historyManager) app.historyManager.saveState();
6024
- return { success: !!ok, action: 'set_target_path', chainId: ${S(input.chainId)} };`);
6850
+ if (!ok) { return { success: false, action: 'set_target_path', error: "no such skeleton, no such IK chain on it, or waypoints was not an array. create_ik_chain returns the chainId to use here" }; }
6851
+ return { success: true, action: 'set_target_path', chainId: ${S(input.chainId)} };`);
6025
6852
  }
6026
6853
  case 'save_pose':
6027
6854
  return wrap('Rigging: save pose', ` const poseId = R.savePose(${S(input.skeletonId)}, ${S(input.name ?? null)});
6028
6855
  if (app.historyManager) app.historyManager.saveState();
6029
- return { success: !!poseId, action: 'save_pose', poseId: poseId };`);
6856
+ if (!poseId) { return { success: false, action: 'save_pose', error: "no such skeleton. A pose is a snapshot of ITS bone angles, so the skeleton has to exist first — call list_skeletons" }; }
6857
+ return { success: true, action: 'save_pose', poseId: poseId };`);
6030
6858
  case 'save_shape_key':
6031
6859
  return wrap('Rigging: save shape key', ` const shapeKeyId = R.saveShapeKey(${S(input.skeletonId)}, ${S(input.name ?? null)});
6032
6860
  if (app.historyManager) app.historyManager.saveState();
6033
- return { success: !!shapeKeyId, action: 'save_shape_key', shapeKeyId: shapeKeyId };`);
6861
+ if (!shapeKeyId) { return { success: false, action: 'save_shape_key', error: "no such skeleton, or nothing is attached to its bones — a shape key snapshots the attached items, so a bare skeleton has nothing to capture" }; }
6862
+ return { success: true, action: 'save_shape_key', shapeKeyId: shapeKeyId };`);
6863
+ // ── Pose motion. Every case below maps 1:1 to a method that exists on
6864
+ // riggingSystem (or, for stitch_poses, a PinePaper facade) — checked
6865
+ // against the engine source, not against the docs, because the docs have
6866
+ // named tools that were never registered before now.
6867
+ case 'list_skeletons':
6868
+ return wrap('Rigging: list skeletons', ` const skeletons = R.listSkeletons();
6869
+ return { success: true, action: 'list_skeletons', skeletons: skeletons, count: skeletons.length };`);
6870
+ case 'list_bones':
6871
+ // Without this an inline pose is unwritable: a pose is
6872
+ // { boneId: angleDeg } and nothing else returns a bone id.
6873
+ return wrap('Rigging: list bones', ` if (typeof R.listBones !== 'function') { return { success: false, error: 'listBones unavailable — update FxTool' }; }
6874
+ const bones = R.listBones(${S(input.skeletonId)});
6875
+ return { success: true, action: 'list_bones', bones: bones, count: bones.length };`);
6876
+ case 'list_pose_libraries':
6877
+ return wrap('Rigging: list pose libraries', ` return (async function() {
6878
+ if (typeof R.ensurePoseLibraries !== 'function') { return { success: false, error: 'pose libraries unavailable — update FxTool' }; }
6879
+ const names = await R.ensurePoseLibraries();
6880
+ return { success: true, action: 'list_pose_libraries', libraries: names.libraries, transitions: names.transitions };
6881
+ })();`);
6882
+ case 'load_pose_library': {
6883
+ // The libraries used to load only from an editor button, so every
6884
+ // agent ran with none and got null from every preset name. Awaited
6885
+ // here so the guarantee is real rather than dependent on whether a
6886
+ // human happened to click something earlier in the session.
6887
+ const map = input.boneMap ? S(input.boneMap) : 'null';
6888
+ return wrap('Rigging: load a stock pose library', ` return (async function() {
6889
+ if (typeof R.ensurePoseLibraries !== 'function') { return { success: false, error: 'pose libraries unavailable — update FxTool' }; }
6890
+ const avail = await R.ensurePoseLibraries();
6891
+ let boneMap = ${map};
6892
+ if (!boneMap) {
6893
+ boneMap = {};
6894
+ for (const b of (R.listBones ? R.listBones(${S(input.skeletonId)}) : [])) { if (b.name) boneMap[b.name] = b.id; }
6895
+ }
6896
+ const res = R.loadPoseLibrary(${S(input.skeletonId)}, ${S(input.libraryName)}, boneMap);
6897
+ if (!res) { return { success: false, action: 'load_pose_library', error: 'no such library, or none of its bone names matched this rig. Available: ' + avail.libraries.join(', '), available: avail.libraries }; }
6898
+ if (app.historyManager) app.historyManager.saveState();
6899
+ return { success: true, action: 'load_pose_library', library: ${S(input.libraryName)}, poses: res };
6900
+ })();`);
6901
+ }
6902
+ case 'list_poses':
6903
+ return wrap('Rigging: list poses', ` const poses = R.listPoses(${S(input.skeletonId ?? null)});
6904
+ return { success: true, action: 'list_poses', poses: poses, count: poses.length };`);
6905
+ case 'load_pose':
6906
+ return wrap('Rigging: load pose', ` const ok = R.loadPose(${S(input.poseId)});
6907
+ if (app.historyManager) app.historyManager.saveState();
6908
+ if (!ok) { return { success: false, action: 'load_pose', error: "no such pose, or the skeleton it was saved against is gone. list_poses for what is still loadable" }; }
6909
+ return { success: true, action: 'load_pose', poseId: ${S(input.poseId)} };`);
6910
+ case 'interpolate_poses':
6911
+ return wrap('Rigging: interpolate poses', ` const ok = R.interpolatePoses(${S(input.poseIdA)}, ${S(input.poseIdB)}, ${input.t ?? 0.5});
6912
+ if (!ok) { return { success: false, action: 'interpolate_poses', error: "one of the two poses does not exist, or they belong to DIFFERENT skeletons — blending across rigs is not defined. Call list_poses to see which skeleton each belongs to" }; }
6913
+ return { success: true, action: 'interpolate_poses', t: ${input.t ?? 0.5} };`);
6914
+ case 'play_pose_sequence': {
6915
+ const opts = S({
6916
+ ...(input.loop !== undefined ? { loop: input.loop } : {}),
6917
+ ...(input.options ?? {}),
6918
+ });
6919
+ return wrap('Rigging: play pose sequence', ` const ok = R.playPoseSequence(${S(input.skeletonId)}, ${S(input.sequence ?? [])}, ${opts});
6920
+ if (!ok) { return { success: false, action: 'play_pose_sequence', error: "no such skeleton, or the sequence was empty. Each key needs a 'pose' id or an inline 'angles' map — call list_poses for the saved ids" }; }
6921
+ return { success: true, action: 'play_pose_sequence', keys: ${(input.sequence ?? []).length} };`);
6922
+ }
6923
+ case 'stop_pose_sequence':
6924
+ return wrap('Rigging: stop pose sequence', ` const ok = R.stopPoseSequence(${S(input.skeletonId)});
6925
+ if (!ok) { return { success: false, action: 'stop_pose_sequence', error: "nothing was playing on that skeleton — this is a no-op rather than a failure" }; }
6926
+ return { success: true, action: 'stop_pose_sequence' };`);
6927
+ case 'stitch_poses': {
6928
+ // The one action on `app` rather than riggingSystem: stitching resolves
6929
+ // saved pose ids and then installs the merged sequence through the
6930
+ // ordinary player, so there is one code path and one thing to scrub.
6931
+ const opts = S({
6932
+ ...(input.blend !== undefined ? { blend: input.blend } : {}),
6933
+ ...(input.blendSteps !== undefined ? { blendSteps: input.blendSteps } : {}),
6934
+ ...(input.matchPhase !== undefined ? { matchPhase: input.matchPhase } : {}),
6935
+ ...(input.loop !== undefined ? { loop: input.loop } : {}),
6936
+ ...(input.plan !== undefined ? { plan: input.plan } : {}),
6937
+ ...(input.options ?? {}),
6938
+ });
6939
+ return wrap('Rigging: stitch pose clips', ` if (typeof app.stitchPoses !== 'function') { return { success: false, error: 'app.stitchPoses unavailable — update FxTool' }; }
6940
+ const res = app.stitchPoses(${S(input.skeletonId)}, ${S(input.clips ?? [])}, ${opts});
6941
+ return Object.assign({ success: !!res.ok, action: 'stitch_poses' }, res);`);
6942
+ }
6943
+ case 'apply_pose_transition':
6944
+ return wrap('Rigging: apply pose transition', ` return (async function() {
6945
+ if (typeof R.ensurePoseLibraries === 'function') { await R.ensurePoseLibraries(); }
6946
+ // A transition names POSES BY NAME, so it needs name → id. Requiring the
6947
+ // caller to build that by hand from list_poses is busywork with one
6948
+ // correct answer, and getting it wrong returns a bare null. Build it here
6949
+ // when it is omitted, exactly as load_pose_library builds its bone map.
6950
+ let poseIdMap = ${input.poseIdMap ? S(input.poseIdMap) : 'null'};
6951
+ if (!poseIdMap || !Object.keys(poseIdMap).length) {
6952
+ poseIdMap = {};
6953
+ for (const pose of (R.listPoses ? R.listPoses(${S(input.skeletonId)}) : [])) { if (pose.name) poseIdMap[pose.name] = pose.id; }
6954
+ }
6955
+ const res = R.applyPoseTransition(${S(input.skeletonId)}, ${S(input.transitionName)}, poseIdMap);
6956
+ if (!res) { return { success: false, action: 'apply_pose_transition', error: "no such transition, or none of its pose names are saved on this skeleton. Load the matching library first (load_pose_library), then retry — the transition plays poses BY NAME." }; }
6957
+ if (app.historyManager) app.historyManager.saveState();
6958
+ return { success: true, action: 'apply_pose_transition', transition: ${S(input.transitionName)} };
6959
+ })();`);
6960
+ case 'auto_walk':
6961
+ case 'auto_breath':
6962
+ case 'auto_idle':
6963
+ case 'auto_jump': {
6964
+ const method = { auto_walk: 'autoWalk', auto_breath: 'autoBreath', auto_idle: 'autoIdle', auto_jump: 'autoJump' }[input.action];
6965
+ // These return null when the rig has no bone the preset recognises BY
6966
+ // NAME, which is a real refusal and not an error — but `result: null`
6967
+ // tells an agent nothing, and a silent nothing is what sends it into a
6968
+ // retry loop. Say which names each one looks for.
6969
+ const needs = {
6970
+ // autoWalk cycles through SAVED poses named walk_00…walk_07 or
6971
+ // walk_contact_L/walk_passing_L/… — the ones the `humanoid` library
6972
+ // saves. It is not a procedural generator, it is a pose player, and
6973
+ // saying "needs a skeleton" when the skeleton is right there sends an
6974
+ // agent looking in the wrong place.
6975
+ auto_walk: "at least 2 saved walk poses (walk_00…, or walk_contact_L / walk_passing_L / walk_contact_R / walk_passing_R). Run load_pose_library with libraryName 'humanoid' (or 'quadruped') first — it saves exactly those",
6976
+ auto_breath: 'a bone named spine, chest, upper_spine or body',
6977
+ auto_idle: 'a bone named head, hip, upper_hub or spine',
6978
+ auto_jump: 'a skeleton, and saved jump poses if you want a posed jump rather than a root arc',
6979
+ };
6980
+ return wrap(`Rigging: ${input.action}`, ` const res = R.${method}(${S(input.skeletonId)}, ${S(input.options ?? {})});
6981
+ if (res && app.historyManager) app.historyManager.saveState();
6982
+ if (!res) { return { success: false, action: ${S(input.action)}, error: ${S(`refused — needs ${needs[input.action]}.`)} }; }
6983
+ return { success: true, action: ${S(input.action)}, result: res };`);
6984
+ }
6985
+ case 'move_root':
6986
+ return wrap('Rigging: root locomotion track', ` const ok = R.moveRoot(${S(input.skeletonId)}, ${S(input.keyframes ?? [])}, ${S(input.options ?? {})});
6987
+ if (!ok) { return { success: false, action: 'move_root', error: "no such skeleton, or keyframes was empty. Each key is { t, x, y } with t in SCENE SECONDS" }; }
6988
+ return { success: true, action: 'move_root', keys: ${(input.keyframes ?? []).length} };`);
6989
+ case 'stop_root_track':
6990
+ return wrap('Rigging: stop root track', ` const ok = R.stopRootTrack(${S(input.skeletonId)});
6991
+ if (!ok) { return { success: false, action: 'stop_root_track', error: "no root track was running on that skeleton — this is a no-op rather than a failure" }; }
6992
+ return { success: true, action: 'stop_root_track' };`);
6993
+ case 'add_secondary_motion':
6994
+ return wrap('Rigging: secondary motion', ` const res = R.addSecondaryMotion(${S(input.skeletonId)}, ${S(input.boneNames ?? [])}, ${S(input.options ?? {})});
6995
+ if (!res) { return { success: false, action: 'add_secondary_motion', error: "no such skeleton, or fewer than 2 of the given bone NAMES resolve on it. A spring chain needs at least two; list_bones for the names this rig uses" }; }
6996
+ return { success: true, action: 'add_secondary_motion', bones: ${(input.boneNames ?? []).length} };`);
6997
+ case 'skin_path':
6998
+ return wrap('Rigging: skin path to bones', ` const res = R.skinPath(${S(input.skeletonId)}, ${S(input.itemId)}, ${S(input.options ?? {})});
6999
+ if (app.historyManager) app.historyManager.saveState();
7000
+ if (!res) { return { success: false, action: 'skin_path', error: "no such skeleton or item, the item is not a path, or no bone is close enough to influence it. Skinning binds vertices to nearby bones, so the path has to overlap the rig — call list_bones to see where they are" }; }
7001
+ return { success: true, action: 'skin_path', itemId: ${S(input.itemId)} };`);
7002
+ case 'bake_animation':
7003
+ return wrap('Rigging: bake animation to keyframes', ` const res = R.bakeAnimation(${S(input.skeletonId)}, ${S(input.options ?? {})});
7004
+ if (res && app.historyManager) app.historyManager.saveState();
7005
+ if (!res) { return { success: false, action: 'bake_animation', error: "nothing to bake — no such skeleton, or no items are ATTACHED to its bones. Baking writes keyframes onto the attached items, so a bare skeleton has no output." }; }
7006
+ return { success: true, action: 'bake_animation', result: res };`);
7007
+ case 'list_shape_keys':
7008
+ return wrap('Rigging: list shape keys', ` const keys = R.listShapeKeys(${S(input.skeletonId)});
7009
+ return { success: true, action: 'list_shape_keys', shapeKeys: keys, count: keys.length };`);
7010
+ case 'load_shape_key':
7011
+ return wrap('Rigging: load shape key', ` const ok = R.loadShapeKey(${S(input.skeletonId)}, ${S(input.shapeKeyId)}, ${input.weight ?? 1});
7012
+ if (!ok) { return { success: false, action: 'load_shape_key', error: "no such skeleton, or no such shape key on it. list_shape_keys for what is saved" }; }
7013
+ return { success: true, action: 'load_shape_key', weight: ${input.weight ?? 1} };`);
6034
7014
  // ── Mocap / rig import — these live on `app` (PinePaper facades), not
6035
7015
  // riggingSystem, and they are async, so they get their own async IIFE
6036
7016
  // instead of the sync `wrap` above. Each returns the facade's own result