@pinepaper.studio/mcp-server 1.5.5 → 1.6.1

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 (80) hide show
  1. package/README.de.md +71 -0
  2. package/README.es.md +71 -0
  3. package/README.fr.md +71 -0
  4. package/README.hi.md +71 -0
  5. package/README.ja.md +71 -0
  6. package/README.ko.md +71 -0
  7. package/README.md +513 -272
  8. package/README.pt-BR.md +71 -0
  9. package/README.zh-CN.md +71 -0
  10. package/assets/easing-splashes.svg +152 -0
  11. package/assets/equation-path.svg +77 -0
  12. package/assets/hero.svg +126 -0
  13. package/assets/live-badge.svg +62 -0
  14. package/assets/poster-sunburst.svg +106 -0
  15. package/dist/__tests__/fixtures/mock-relations.d.ts +48 -0
  16. package/dist/__tests__/fixtures/mock-relations.d.ts.map +1 -1
  17. package/dist/__tests__/fixtures/mock-relations.js +31 -0
  18. package/dist/__tests__/fixtures/mock-relations.js.map +1 -1
  19. package/dist/agent/types.d.ts +6 -6
  20. package/dist/browser/bridge.d.ts +48 -4
  21. package/dist/browser/bridge.d.ts.map +1 -1
  22. package/dist/browser/bridge.js +79 -18
  23. package/dist/browser/bridge.js.map +1 -1
  24. package/dist/browser/puppeteer-controller.d.ts +34 -0
  25. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  26. package/dist/browser/puppeteer-controller.js +116 -5
  27. package/dist/browser/puppeteer-controller.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +32 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/ontology/index.d.ts +2 -0
  32. package/dist/ontology/index.d.ts.map +1 -1
  33. package/dist/ontology/index.js +2 -0
  34. package/dist/ontology/index.js.map +1 -1
  35. package/dist/ontology/ontology-compiler.d.ts +78 -0
  36. package/dist/ontology/ontology-compiler.d.ts.map +1 -0
  37. package/dist/ontology/ontology-compiler.js +235 -0
  38. package/dist/ontology/ontology-compiler.js.map +1 -0
  39. package/dist/ontology/ontology-validator.d.ts +103 -0
  40. package/dist/ontology/ontology-validator.d.ts.map +1 -0
  41. package/dist/ontology/ontology-validator.js +573 -0
  42. package/dist/ontology/ontology-validator.js.map +1 -0
  43. package/dist/ontology/types.d.ts +1 -0
  44. package/dist/ontology/types.d.ts.map +1 -1
  45. package/dist/ontology/validator.d.ts +6 -0
  46. package/dist/ontology/validator.d.ts.map +1 -1
  47. package/dist/ontology/validator.js +34 -5
  48. package/dist/ontology/validator.js.map +1 -1
  49. package/dist/ontology/vocabulary.d.ts.map +1 -1
  50. package/dist/ontology/vocabulary.js +91 -3
  51. package/dist/ontology/vocabulary.js.map +1 -1
  52. package/dist/tools/definitions.d.ts +1 -1
  53. package/dist/tools/definitions.d.ts.map +1 -1
  54. package/dist/tools/definitions.js +978 -27
  55. package/dist/tools/definitions.js.map +1 -1
  56. package/dist/tools/handlers/maps.d.ts.map +1 -1
  57. package/dist/tools/handlers/maps.js +54 -1
  58. package/dist/tools/handlers/maps.js.map +1 -1
  59. package/dist/tools/handlers/ontology.d.ts.map +1 -1
  60. package/dist/tools/handlers/ontology.js +15 -3
  61. package/dist/tools/handlers/ontology.js.map +1 -1
  62. package/dist/tools/handlers.d.ts.map +1 -1
  63. package/dist/tools/handlers.js +137 -6
  64. package/dist/tools/handlers.js.map +1 -1
  65. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  66. package/dist/tools/minimal-descriptions.js +20 -1
  67. package/dist/tools/minimal-descriptions.js.map +1 -1
  68. package/dist/tools/toolkits.d.ts.map +1 -1
  69. package/dist/tools/toolkits.js +25 -2
  70. package/dist/tools/toolkits.js.map +1 -1
  71. package/dist/types/code-generator.d.ts +190 -2
  72. package/dist/types/code-generator.d.ts.map +1 -1
  73. package/dist/types/code-generator.js +1068 -30
  74. package/dist/types/code-generator.js.map +1 -1
  75. package/dist/types/schemas.d.ts +2726 -116
  76. package/dist/types/schemas.d.ts.map +1 -1
  77. package/dist/types/schemas.js +594 -4
  78. package/dist/types/schemas.js.map +1 -1
  79. package/manifest.json +299 -71
  80. package/package.json +14 -3
@@ -16,10 +16,13 @@ CreateLetterCollageInputSchema, AnimateLetterCollageInputSchema,
16
16
  LoadMapInputSchema, HighlightRegionsInputSchema, UnhighlightRegionsInputSchema, ApplyDataColorsInputSchema, AddMarkerInputSchema, AddMapLabelsInputSchema, PanMapInputSchema, ZoomMapInputSchema, ImportCustomMapInputSchema, GetRegionAtPointInputSchema,
17
17
  // Map animation/CSV types
18
18
  AnimateMapRegionsInputSchema, AnimateMapWaveInputSchema, StopMapAnimationsInputSchema, ExportMapRegionCSVInputSchema, ImportMapRegionCSVInputSchema, SelectMapRegionsInputSchema, DeselectMapRegionsInputSchema,
19
+ // Globe + world tour types
20
+ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTourInputSchema, StopWorldTourInputSchema, PinToGlobeInputSchema, TourItemInputSchema,
19
21
  // Custom relation/code types
20
22
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
21
23
  // Mask types
22
24
  ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, } from './schemas.js';
25
+ import { OntologyCompiler } from '../ontology/ontology-compiler.js';
23
26
  // =============================================================================
24
27
  // HELPER FUNCTIONS
25
28
  // =============================================================================
@@ -273,7 +276,9 @@ const relations = results.map(r => ({
273
276
  /**
274
277
  * Template for simple animation
275
278
  */
276
- function generateAnimateCode(itemId, animationType, speed) {
279
+ function generateAnimateCode(itemId, animationType, speed, intensity, delay) {
280
+ const extra = (intensity !== undefined ? `,\n animationIntensity: ${intensity}` : '') +
281
+ (delay !== undefined ? `,\n animationDelay: ${delay}` : '');
277
282
  return `
278
283
  // Apply ${animationType} animation to ${itemId}
279
284
  const item = app.getItemById('${itemId}');
@@ -282,7 +287,7 @@ if (!item) {
282
287
  }
283
288
  app.animate(item, {
284
289
  animationType: '${animationType}',
285
- animationSpeed: ${speed}
290
+ animationSpeed: ${speed}${extra}
286
291
  });
287
292
 
288
293
  ({ success: true, itemId: '${itemId}', animationType: '${animationType}' });
@@ -307,7 +312,7 @@ function normalizeKeyframePositions(keyframes) {
307
312
  /**
308
313
  * Template for keyframe animation
309
314
  */
310
- function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint) {
315
+ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
311
316
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
312
317
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
313
318
  const opts = { duration: calculatedDuration, loop };
@@ -317,6 +322,8 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
317
322
  opts.clipInPoint = clipInPoint;
318
323
  if (clipOutPoint !== undefined)
319
324
  opts.clipOutPoint = clipOutPoint;
325
+ if (timeUnits !== undefined)
326
+ opts.timeUnits = timeUnits;
320
327
  return `
321
328
  // Apply keyframe animation to ${itemId}
322
329
  app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
@@ -327,12 +334,15 @@ app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
327
334
  /**
328
335
  * Template for executing generators
329
336
  */
330
- function generateExecuteGeneratorCode(generatorName, params) {
337
+ function generateExecuteGeneratorCode(generatorName, params, region) {
338
+ // FxTool signature: app.executeGenerator(name, params, options).
339
+ // region lives in options (added in FxTool c81781c).
340
+ const options = region ? { region } : {};
331
341
  return `
332
- // Execute ${generatorName} generator
342
+ // Execute ${generatorName} generator${region ? ` in region ${region.width}×${region.height} @ (${region.x},${region.y})` : ''}
333
343
  (async function() {
334
- await app.executeGenerator('${generatorName}', ${JSON.stringify(params, null, 2)});
335
- return { success: true, generator: '${generatorName}' };
344
+ await app.executeGenerator('${generatorName}', ${JSON.stringify(params, null, 2)}, ${JSON.stringify(options)});
345
+ return { success: true, generator: '${generatorName}'${region ? ', region: ' + JSON.stringify(region) : ''} };
336
346
  })();
337
347
  `.trim();
338
348
  }
@@ -401,12 +411,12 @@ const items = filtered.map(entry => ({
401
411
  /**
402
412
  * Template for timeline control
403
413
  */
404
- function generatePlayTimelineCode(action, duration, loop, time) {
414
+ function generatePlayTimelineCode(action, duration, loop, time, deterministic) {
405
415
  switch (action) {
406
416
  case 'play':
407
417
  return `
408
418
  // Play keyframe timeline
409
- app.playKeyframeTimeline(${duration || 5}, ${loop ?? false});
419
+ app.playKeyframeTimeline(${duration || 5}, ${loop ?? false}, { timeUnits: 'seconds' });
410
420
  ({ success: true, action: 'play', duration: ${duration || 5}, loop: ${loop ?? false} });
411
421
  `.trim();
412
422
  case 'pause':
@@ -422,6 +432,18 @@ app.stopKeyframeTimeline();
422
432
  ({ success: true, action: 'stop' });
423
433
  `.trim();
424
434
  case 'seek':
435
+ if (deterministic) {
436
+ return `
437
+ // Deterministic seek — evaluate the whole scene at the exact time (keyframes + relations + generators)
438
+ (function() {
439
+ const t = ${time || 0};
440
+ if (typeof app.sceneAt === 'function') { app.sceneAt(t); return { success: true, action: 'seek', time: t, deterministic: true }; }
441
+ // Fallback for older FxTool builds without sceneAt
442
+ app.setPlaybackTime(t);
443
+ return { success: true, action: 'seek', time: t, deterministic: false, note: 'app.sceneAt unavailable — used setPlaybackTime (keyframe state only); update FxTool for deterministic relations/generators' };
444
+ })();
445
+ `.trim();
446
+ }
425
447
  return `
426
448
  // Seek to time
427
449
  app.setPlaybackTime(${time || 0});
@@ -555,6 +577,82 @@ const formatted = generators.map(g => ({
555
577
  ({ generators: formatted });
556
578
  `.trim();
557
579
  }
580
+ /**
581
+ * True when app.create() derives this item's geometry from explicit coordinates
582
+ * and therefore ignores params.position:
583
+ * - path built from `segments` or `pathData`
584
+ * - line built from `from`/`to`
585
+ * - arc built from `from`/`through`/`to`
586
+ * Without those coordinate props these types fall back to the position-derived
587
+ * point, so create() already places them correctly and we must not interfere.
588
+ */
589
+ export function isCoordinateBuilt(itemType, props) {
590
+ switch (itemType) {
591
+ case 'path':
592
+ return props.segments !== undefined || props.pathData !== undefined;
593
+ case 'line':
594
+ return props.from !== undefined || props.to !== undefined;
595
+ case 'arc':
596
+ return props.from !== undefined || props.through !== undefined || props.to !== undefined;
597
+ default:
598
+ return false;
599
+ }
600
+ }
601
+ /**
602
+ * app.addAnimation() REPLACES data.keyframes wholesale, so two
603
+ * keyframe_animate ops targeting one item meant the first track was silently
604
+ * discarded — the batch still reported success while (e.g.) a rotation channel
605
+ * vanished because a later op set a colour channel on the same item.
606
+ *
607
+ * Plan a merge: fold every duplicate target's keyframes into its FIRST op
608
+ * (keeping author order), union by timestamp with later ops winning on a
609
+ * per-property conflict. Op slots are preserved so `results` stays 1:1 with
610
+ * `operations` and `$N` indices are untouched; the folded slots emit an
611
+ * explicit `{ merged: true }` marker so the merge is visible to the caller
612
+ * rather than being another silent rewrite.
613
+ */
614
+ export function planKeyframeMerges(operations) {
615
+ const byTarget = new Map();
616
+ operations.forEach((op, i) => {
617
+ if (op.type !== 'keyframe_animate' || !op.itemId)
618
+ return;
619
+ const list = byTarget.get(op.itemId) || [];
620
+ list.push(i);
621
+ byTarget.set(op.itemId, list);
622
+ });
623
+ const plan = { merged: new Map(), foldedInto: new Map() };
624
+ for (const indices of byTarget.values()) {
625
+ if (indices.length < 2)
626
+ continue;
627
+ const first = indices[0];
628
+ // Union keyframes by time; later ops shallow-merge over earlier ones.
629
+ const byTime = new Map();
630
+ let duration = 0;
631
+ let loop = false;
632
+ for (const i of indices) {
633
+ const op = operations[i];
634
+ for (const kf of op.keyframes || []) {
635
+ const prev = byTime.get(kf.time);
636
+ byTime.set(kf.time, {
637
+ time: kf.time,
638
+ properties: { ...(prev?.properties || {}), ...(kf.properties || {}) },
639
+ ...((kf.easing ?? prev?.easing) !== undefined ? { easing: kf.easing ?? prev?.easing } : {}),
640
+ });
641
+ }
642
+ const opMax = op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 0;
643
+ duration = Math.max(duration, op.duration || opMax);
644
+ loop = loop || op.loop === true;
645
+ if (i !== first)
646
+ plan.foldedInto.set(i, first);
647
+ }
648
+ plan.merged.set(first, {
649
+ keyframes: [...byTime.values()].sort((a, b) => a.time - b.time),
650
+ duration: duration || 5,
651
+ loop,
652
+ });
653
+ }
654
+ return plan;
655
+ }
558
656
  /**
559
657
  * Template for batch create - creates multiple items with single history save
560
658
  */
@@ -638,6 +736,10 @@ export class PinePaperCodeGenerator {
638
736
  properties.animationType = validated.animationType;
639
737
  if (validated.animationSpeed !== undefined)
640
738
  properties.animationSpeed = validated.animationSpeed;
739
+ if (validated.animationIntensity !== undefined)
740
+ properties.animationIntensity = validated.animationIntensity;
741
+ if (validated.animationDelay !== undefined)
742
+ properties.animationDelay = validated.animationDelay;
641
743
  if (validated.keyframes !== undefined)
642
744
  properties.keyframes = validated.keyframes;
643
745
  return generateCreateItemCode(validated.itemType, validated.position, properties);
@@ -679,21 +781,21 @@ export class PinePaperCodeGenerator {
679
781
  */
680
782
  generateAnimate(input) {
681
783
  const validated = AnimateItemInputSchema.parse(input);
682
- return generateAnimateCode(validated.itemId, validated.animationType, validated.speed);
784
+ return generateAnimateCode(validated.itemId, validated.animationType, validated.speed, validated.intensity, validated.delay);
683
785
  }
684
786
  /**
685
787
  * Generate code for keyframe animation
686
788
  */
687
789
  generateKeyframeAnimate(input) {
688
790
  const validated = KeyframeAnimateInputSchema.parse(input);
689
- return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint);
791
+ return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits);
690
792
  }
691
793
  /**
692
794
  * Generate code for executing a generator
693
795
  */
694
796
  generateExecuteGenerator(input) {
695
797
  const validated = ExecuteGeneratorInputSchema.parse(input);
696
- return generateExecuteGeneratorCode(validated.generatorName, validated.params);
798
+ return generateExecuteGeneratorCode(validated.generatorName, validated.params, validated.region);
697
799
  }
698
800
  /**
699
801
  * Generate code for applying an effect
@@ -711,8 +813,8 @@ export class PinePaperCodeGenerator {
711
813
  /**
712
814
  * Generate code for timeline control
713
815
  */
714
- generatePlayTimeline(action, duration, loop, time) {
715
- return generatePlayTimelineCode(action, duration, loop, time);
816
+ generatePlayTimeline(action, duration, loop, time, deterministic) {
817
+ return generatePlayTimelineCode(action, duration, loop, time, deterministic);
716
818
  }
717
819
  /**
718
820
  * Generate code for setting background color
@@ -870,6 +972,28 @@ throw new Error('Either svgString or url must be provided');
870
972
  viewport: ctx.viewport,
871
973
  };
872
974
  })();
975
+ `.trim();
976
+ }
977
+ /**
978
+ * Generate code for semantic validation (FxTool OntologyValidator) — runs in the
979
+ * browser against the live scene and returns structured diagnostics.
980
+ */
981
+ generateValidate(input) {
982
+ const mode = input.mode || 'scene';
983
+ const opJson = JSON.stringify(input.op ?? null);
984
+ return `
985
+ // Semantic validation — structured diagnostics
986
+ (function() {
987
+ if (typeof app.validateScene !== 'function' || typeof app.validateOp !== 'function') {
988
+ return {
989
+ success: false,
990
+ error: 'app.validateScene/validateOp not available — requires FxTool with OntologyValidator v1 (commit 7f151f4 or later).',
991
+ };
992
+ }
993
+ var mode = ${JSON.stringify(mode)};
994
+ var result = (mode === 'op') ? app.validateOp(${opJson}) : app.validateScene();
995
+ return { success: true, mode: mode, ok: result.ok, diagnostics: result.diagnostics };
996
+ })();
873
997
  `.trim();
874
998
  }
875
999
  /**
@@ -1728,6 +1852,9 @@ throw new Error('Unknown diagram mode action: ${action}');
1728
1852
  const validated = AgentBatchExecuteInputSchema.parse(input);
1729
1853
  const { operations, atomic } = validated;
1730
1854
  const isAtomic = atomic !== false;
1855
+ // Fold duplicate keyframe_animate targets so a later op can't silently
1856
+ // clobber an earlier track (app.addAnimation replaces, never merges).
1857
+ const mergePlan = planKeyframeMerges(operations);
1731
1858
  let code = `
1732
1859
  // Batch execute ${operations.length} operations
1733
1860
  (async function() {
@@ -1738,7 +1865,17 @@ throw new Error('Unknown diagram mode action: ${action}');
1738
1865
  try {
1739
1866
  `;
1740
1867
  operations.forEach((op, index) => {
1741
- const opCode = this.generateBatchOperationCode(op, index);
1868
+ const opCode = this.generateBatchOperationCode(op, index, mergePlan);
1869
+ // $N variable references are documented as "items CREATED in earlier
1870
+ // operations" — only `create` results may extend the itemIds array.
1871
+ // animate/keyframe_animate/modify/delete also return { itemId } (the
1872
+ // TARGET's id), and unconditionally pushing those shifted every later
1873
+ // $N reference: a batch of [create ×5, keyframe_animate ×5, create ×5,
1874
+ // keyframe_animate ×5] silently re-animated the FIRST five items and
1875
+ // left the second five without keyframes (mis-oriented/mis-animated
1876
+ // vehicles, 2026-07-30). `group` already follows this rule by
1877
+ // returning groupId instead of itemId.
1878
+ const claimsItemSlot = op.type === 'create';
1742
1879
  code += `
1743
1880
  // Operation ${index}: ${op.type}
1744
1881
  try {
@@ -1746,9 +1883,9 @@ throw new Error('Unknown diagram mode action: ${action}');
1746
1883
  ${opCode}
1747
1884
  })();
1748
1885
  results.push({ index: ${index}, success: true, result: result${index} });
1749
- if (result${index} && result${index}.itemId) {
1886
+ ${claimsItemSlot ? `if (result${index} && result${index}.itemId) {
1750
1887
  itemIds.push(result${index}.itemId);
1751
- }
1888
+ }` : ''}
1752
1889
  } catch (opError) {
1753
1890
  results.push({ index: ${index}, success: false, error: opError.message });
1754
1891
  ${isAtomic ? 'throw opError;' : 'success = false;'}
@@ -1772,7 +1909,7 @@ throw new Error('Unknown diagram mode action: ${action}');
1772
1909
  /**
1773
1910
  * Generate code for a single batch operation
1774
1911
  */
1775
- generateBatchOperationCode(op, index) {
1912
+ generateBatchOperationCode(op, index, mergePlan) {
1776
1913
  switch (op.type) {
1777
1914
  case 'create': {
1778
1915
  const pos = op.position || { x: 400, y: 300 };
@@ -1790,6 +1927,19 @@ const itemId = item.data && item.data.id ? item.data.id : app.registerItem(item,
1790
1927
  if (createProps.opacity !== undefined) {
1791
1928
  createCode += `\nif ('opacity' in item) item.opacity = ${JSON.stringify(createProps.opacity)};`;
1792
1929
  }
1930
+ // Coordinate-built items (path from segments/pathData, line/arc from
1931
+ // from/through/to) derive their geometry from those coordinates and
1932
+ // IGNORE params.position — so `create` at a point silently produced an
1933
+ // item at its raw coordinates instead. Re-seat it after create when the
1934
+ // caller explicitly asked for a position. Guarded on an explicit
1935
+ // op.position so absolute-coordinate geometry (the common case, which
1936
+ // passes no position) is never snapped to the injected default.
1937
+ if (op.position && isCoordinateBuilt(op.itemType, createProps)) {
1938
+ createCode += `
1939
+ // create() builds this item type from its own coordinates and ignores
1940
+ // params.position — apply the caller's explicit position after the fact.
1941
+ if (item.position) item.position = new paper.Point(${pos.x}, ${pos.y});`;
1942
+ }
1793
1943
  createCode += `
1794
1944
  // Ensure item is visible above backgrounds/generators
1795
1945
  if (item.bringToFront) item.bringToFront();
@@ -1850,6 +2000,17 @@ item.remove();
1850
2000
  if (app.itemRegistry) app.itemRegistry.unregister(targetId);
1851
2001
  return { itemId: targetId, deleted: true };
1852
2002
  `;
2003
+ case 'group': {
2004
+ // Resolve each ref ($N → itemIds[N]; literal → quoted id) into a JS array.
2005
+ const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `itemIds[${ref.substring(1)}]` : `'${ref}'`);
2006
+ const idsArrayExpr = `[${refs.join(', ')}]`;
2007
+ const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
2008
+ // Returns { groupId, ... } (no itemId) so it does NOT shift $N indices — like relation.
2009
+ return `
2010
+ return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
2011
+ })();
2012
+ `;
2013
+ }
1853
2014
  case 'set_background':
1854
2015
  const bgColor = op.backgroundColor || op.properties?.color || '#000000';
1855
2016
  return `
@@ -1859,9 +2020,10 @@ return { success: true, backgroundColor: '${bgColor}' };
1859
2020
  case 'execute_generator':
1860
2021
  const genName = op.generatorName || 'drawSunburst';
1861
2022
  const genParams = JSON.stringify(op.generatorParams || {});
2023
+ const genOptions = JSON.stringify(op.generatorRegion ? { region: op.generatorRegion } : {});
1862
2024
  return `
1863
- await app.executeGenerator('${genName}', ${genParams});
1864
- return { success: true, generator: '${genName}' };
2025
+ await app.executeGenerator('${genName}', ${genParams}, ${genOptions});
2026
+ return { success: true, generator: '${genName}'${op.generatorRegion ? ', region: ' + JSON.stringify(op.generatorRegion) : ''} };
1865
2027
  `;
1866
2028
  case 'set_canvas_size': {
1867
2029
  const w = op.width || 1080;
@@ -1874,12 +2036,25 @@ return { success: true, width: ${w}, height: ${h} };
1874
2036
  `;
1875
2037
  }
1876
2038
  case 'keyframe_animate': {
2039
+ // Folded into an earlier op on the same target (see planKeyframeMerges).
2040
+ const foldTarget = mergePlan?.foldedInto.get(index);
2041
+ if (foldTarget !== undefined) {
2042
+ return `
2043
+ // Keyframes merged into operation ${foldTarget} (same target). A keyframe track
2044
+ // is replaced wholesale, so emitting both here would silently drop one.
2045
+ return { itemId: ${op.itemId?.startsWith('$') ? `itemIds[${op.itemId.substring(1)}]` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
2046
+ `;
2047
+ }
2048
+ const mergedPayload = mergePlan?.merged.get(index);
1877
2049
  const kfItemRef = op.itemId?.startsWith('$')
1878
2050
  ? `itemIds[${op.itemId.substring(1)}]`
1879
2051
  : `'${op.itemId}'`;
1880
- const kfJson = JSON.stringify(normalizeKeyframePositions(op.keyframes || []));
1881
- const kfDuration = op.duration || (op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 5);
1882
- const kfLoop = op.loop ?? false;
2052
+ const kfSource = mergedPayload ? mergedPayload.keyframes : (op.keyframes || []);
2053
+ const kfJson = JSON.stringify(normalizeKeyframePositions(kfSource));
2054
+ const kfDuration = mergedPayload
2055
+ ? mergedPayload.duration
2056
+ : (op.duration || (op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 5));
2057
+ const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? false);
1883
2058
  return `
1884
2059
  const targetId = ${kfItemRef};
1885
2060
  app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop} });
@@ -1920,7 +2095,7 @@ return { itemId: targetId, effectType: '${op.effectType || 'sparkle'}' };
1920
2095
  const ptLoop = op.loop ?? false;
1921
2096
  if (ptAction === 'play') {
1922
2097
  return `
1923
- app.playKeyframeTimeline(${ptDuration}, ${ptLoop});
2098
+ app.playKeyframeTimeline(${ptDuration}, ${ptLoop}, { timeUnits: 'seconds' });
1924
2099
  return { success: true, action: 'play', duration: ${ptDuration}, loop: ${ptLoop} };
1925
2100
  `;
1926
2101
  }
@@ -2259,7 +2434,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2259
2434
  */
2260
2435
  generateCreateLetterCollage(input) {
2261
2436
  const validated = CreateLetterCollageInputSchema.parse(input);
2262
- const { text, style = 'tile', palette, position, fontSize = 48, fontFamily = 'Inter, sans-serif', spacing = 1.1, gradientPalette, gradientDirection = 'vertical', cornerRadius = 4, shadowEnabled = true, } = validated;
2437
+ const { text, style = 'tile', palette, position, fontSize = 48, fontFamily = 'Inter, sans-serif', spacing = 1.1, gradientPalette, gradientDirection = 'vertical', cornerRadius = 4, shadowEnabled = true, particleSpacing, particleDotSize, } = validated;
2263
2438
  // Build options object
2264
2439
  const options = {
2265
2440
  style,
@@ -2277,6 +2452,10 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2277
2452
  options.gradientPalette = gradientPalette;
2278
2453
  if (gradientDirection)
2279
2454
  options.gradientDirection = gradientDirection;
2455
+ if (particleSpacing !== undefined)
2456
+ options.particleSpacing = particleSpacing;
2457
+ if (particleDotSize !== undefined)
2458
+ options.particleDotSize = particleDotSize;
2280
2459
  return `
2281
2460
  // Create letter collage
2282
2461
  (function() {
@@ -2858,6 +3037,198 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2858
3037
  return { success: false, error: error.message };
2859
3038
  }
2860
3039
  })();
3040
+ `.trim();
3041
+ }
3042
+ // -------------------------------------------------------------------------
3043
+ // Globe + World Tour
3044
+ // -------------------------------------------------------------------------
3045
+ /**
3046
+ * Generate code to enable globe (orthographic) mode
3047
+ */
3048
+ generateGlobeEnable(input) {
3049
+ const validated = GlobeEnableInputSchema.parse(input);
3050
+ const optionsStr = JSON.stringify(validated);
3051
+ return `
3052
+ // Enable globe mode
3053
+ (function() {
3054
+ if (!app.mapSystem) {
3055
+ return { success: false, error: 'Map system not available' };
3056
+ }
3057
+ if (typeof app.mapSystem.enableGlobeMode !== 'function') {
3058
+ return { success: false, error: 'enableGlobeMode unavailable on this build (update the canvas)' };
3059
+ }
3060
+
3061
+ try {
3062
+ app.mapSystem.enableGlobeMode(${optionsStr});
3063
+ return { success: true };
3064
+ } catch (error) {
3065
+ return { success: false, error: error.message };
3066
+ }
3067
+ })();
3068
+ `.trim();
3069
+ }
3070
+ /**
3071
+ * Generate code to rotate the globe to a coordinate (versor slerp)
3072
+ */
3073
+ generateGlobeRotateTo(input) {
3074
+ const validated = GlobeRotateToInputSchema.parse(input);
3075
+ const durationArg = validated.duration !== undefined ? `, ${validated.duration}` : '';
3076
+ return `
3077
+ // Rotate globe to coordinate
3078
+ (function() {
3079
+ if (!app.mapSystem) {
3080
+ return { success: false, error: 'Map system not available' };
3081
+ }
3082
+ if (typeof app.mapSystem.rotateGlobeTo !== 'function') {
3083
+ return { success: false, error: 'rotateGlobeTo unavailable on this build (update the canvas)' };
3084
+ }
3085
+
3086
+ try {
3087
+ app.mapSystem.rotateGlobeTo(${validated.lon}, ${validated.lat}${durationArg});
3088
+ return { success: true, rotateTo: [${validated.lon}, ${validated.lat}] };
3089
+ } catch (error) {
3090
+ return { success: false, error: error.message };
3091
+ }
3092
+ })();
3093
+ `.trim();
3094
+ }
3095
+ /**
3096
+ * Generate code to continuously spin the globe
3097
+ */
3098
+ generateGlobeSpin(input) {
3099
+ const validated = GlobeSpinInputSchema.parse(input);
3100
+ const optionsStr = JSON.stringify(validated);
3101
+ return `
3102
+ // Spin globe continuously
3103
+ (function() {
3104
+ if (!app.mapSystem) {
3105
+ return { success: false, error: 'Map system not available' };
3106
+ }
3107
+ if (typeof app.mapSystem.animateGlobeRotation !== 'function') {
3108
+ return { success: false, error: 'animateGlobeRotation unavailable on this build (update the canvas)' };
3109
+ }
3110
+
3111
+ try {
3112
+ const callbackId = app.mapSystem.animateGlobeRotation(${optionsStr});
3113
+ return { success: true, callbackId: callbackId || null };
3114
+ } catch (error) {
3115
+ return { success: false, error: error.message };
3116
+ }
3117
+ })();
3118
+ `.trim();
3119
+ }
3120
+ /**
3121
+ * Generate code to run a world tour across regions or coordinates
3122
+ */
3123
+ generateWorldTour(input) {
3124
+ const validated = WorldTourInputSchema.parse(input);
3125
+ const optionsStr = JSON.stringify(validated);
3126
+ return `
3127
+ // World tour across regions / coordinates
3128
+ (function() {
3129
+ if (!app.mapSystem) {
3130
+ return { success: false, error: 'Map system not available' };
3131
+ }
3132
+ if (typeof app.mapSystem.worldTour !== 'function') {
3133
+ return { success: false, error: 'worldTour unavailable on this build (update the canvas)' };
3134
+ }
3135
+
3136
+ try {
3137
+ const tourId = app.mapSystem.worldTour(${optionsStr});
3138
+ return { success: true, tourId: tourId || null };
3139
+ } catch (error) {
3140
+ return { success: false, error: error.message };
3141
+ }
3142
+ })();
3143
+ `.trim();
3144
+ }
3145
+ /**
3146
+ * Generate code to stop a running world tour
3147
+ */
3148
+ generateStopWorldTour(input) {
3149
+ const validated = StopWorldTourInputSchema.parse(input);
3150
+ const idArg = validated.id !== undefined ? JSON.stringify(validated.id) : '';
3151
+ return `
3152
+ // Stop world tour
3153
+ (function() {
3154
+ if (!app.mapSystem) {
3155
+ return { success: false, error: 'Map system not available' };
3156
+ }
3157
+ if (typeof app.mapSystem.stopWorldTour !== 'function') {
3158
+ return { success: false, error: 'stopWorldTour unavailable on this build (update the canvas)' };
3159
+ }
3160
+
3161
+ try {
3162
+ app.mapSystem.stopWorldTour(${idArg});
3163
+ return { success: true };
3164
+ } catch (error) {
3165
+ return { success: false, error: error.message };
3166
+ }
3167
+ })();
3168
+ `.trim();
3169
+ }
3170
+ /**
3171
+ * Generate code to pin a canvas item to a globe surface coordinate
3172
+ */
3173
+ generatePinToGlobe(input) {
3174
+ const validated = PinToGlobeInputSchema.parse(input);
3175
+ const itemId = validated.itemId;
3176
+ const opts = JSON.stringify({ hideOnFarSide: validated.hideOnFarSide });
3177
+ return `
3178
+ // Pin item to globe coordinate
3179
+ (function() {
3180
+ if (!app.mapSystem) {
3181
+ return { success: false, error: 'Map system not available' };
3182
+ }
3183
+ if (typeof app.mapSystem.pinItemToGlobe !== 'function') {
3184
+ return { success: false, error: 'pinItemToGlobe unavailable on this build (update the canvas)' };
3185
+ }
3186
+
3187
+ const item = (app.getItemById && app.getItemById(${JSON.stringify(itemId)})) || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}) && (app.itemRegistry.get(${JSON.stringify(itemId)}).item || app.itemRegistry.get(${JSON.stringify(itemId)})));
3188
+ if (!item) {
3189
+ return { success: false, error: 'Item ${itemId} not found' };
3190
+ }
3191
+
3192
+ try {
3193
+ const pinned = app.mapSystem.pinItemToGlobe(item, ${validated.lon}, ${validated.lat}, ${opts});
3194
+ return { success: !!pinned, pinned: !!pinned };
3195
+ } catch (error) {
3196
+ return { success: false, error: error.message };
3197
+ }
3198
+ })();
3199
+ `.trim();
3200
+ }
3201
+ /**
3202
+ * Generate code to tour a canvas item along regions or coordinates
3203
+ */
3204
+ generateTourItem(input) {
3205
+ const validated = TourItemInputSchema.parse(input);
3206
+ const itemId = validated.itemId;
3207
+ const { itemId: _omit, regions, coords, ...rest } = validated;
3208
+ const optsStr = JSON.stringify(rest);
3209
+ return `
3210
+ // Tour item along regions / coordinates
3211
+ (function() {
3212
+ if (!app.mapSystem) {
3213
+ return { success: false, error: 'Map system not available' };
3214
+ }
3215
+
3216
+ const item = (app.getItemById && app.getItemById(${JSON.stringify(itemId)})) || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}) && (app.itemRegistry.get(${JSON.stringify(itemId)}).item || app.itemRegistry.get(${JSON.stringify(itemId)})));
3217
+ if (!item) {
3218
+ return { success: false, error: 'Item ${itemId} not found' };
3219
+ }
3220
+
3221
+ try {
3222
+ ${regions
3223
+ ? `if (typeof app.mapSystem.tourRegions !== 'function') { return { success: false, error: 'tourRegions unavailable on this build (update the canvas)' }; }
3224
+ const tourId = app.mapSystem.tourRegions(item, ${JSON.stringify(regions)}, ${optsStr});`
3225
+ : `if (typeof app.mapSystem.tourItemAlongCoords !== 'function') { return { success: false, error: 'tourItemAlongCoords unavailable on this build (update the canvas)' }; }
3226
+ const tourId = app.mapSystem.tourItemAlongCoords(item, ${JSON.stringify(coords || [])}, ${optsStr});`}
3227
+ return { success: true, tourId: tourId || null };
3228
+ } catch (error) {
3229
+ return { success: false, error: error.message };
3230
+ }
3231
+ })();
2861
3232
  `.trim();
2862
3233
  }
2863
3234
  /**
@@ -3715,15 +4086,23 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
3715
4086
  // IMAGE PROCESSING
3716
4087
  // ===========================================================================
3717
4088
  generateImageFilter(input) {
3718
- const guard = `if (!app.imageTools) return { error: 'ImageTools not available' };`;
4089
+ // Resolve the raster from the registry id (unwrapping groups when the
4090
+ // build has the _resolveRaster helper), then use the real GPU-filter
4091
+ // facades. The previous emitter called app.imageTools.applyFilter, a
4092
+ // method that never existed — the tool errored on every use.
4093
+ const resolve = ` const item = (typeof app._resolveRaster === 'function')
4094
+ ? app._resolveRaster(${JSON.stringify(input.itemId)})
4095
+ : (app.itemRegistry ? app.itemRegistry.getItem(${JSON.stringify(input.itemId)}) : null);
4096
+ if (!item) return { error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
4097
+ if (typeof app.applyImageFilter !== 'function') return { error: 'GPU image filters unavailable — update FxTool' };`;
3719
4098
  switch (input.action) {
3720
4099
  case 'apply':
3721
4100
  return `
3722
4101
  // Apply image filter
3723
4102
  (async function() {
3724
- ${guard}
4103
+ ${resolve}
3725
4104
  try {
3726
- const result = await app.imageTools.applyFilter(${JSON.stringify(input.itemId)}, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(input.params || {})});
4105
+ await app.applyImageFilter(item, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(input.params || {})});
3727
4106
  return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
3728
4107
  } catch (e) {
3729
4108
  return { error: 'Failed to apply filter: ' + e.message };
@@ -3733,10 +4112,10 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
3733
4112
  return `
3734
4113
  // Apply filter chain
3735
4114
  (async function() {
3736
- ${guard}
4115
+ ${resolve}
3737
4116
  try {
3738
4117
  const filters = ${JSON.stringify(input.filters || [])};
3739
- const result = await app.imageTools.applyFilterChain(${JSON.stringify(input.itemId)}, filters);
4118
+ await app.applyImageFilterChain(item, filters);
3740
4119
  return { success: true, action: 'chain', itemId: ${JSON.stringify(input.itemId)}, filterCount: filters.length };
3741
4120
  } catch (e) {
3742
4121
  return { error: 'Failed to apply filter chain: ' + e.message };
@@ -4489,6 +4868,665 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4489
4868
  return `(function() { return { error: 'Unknown measurement action: ${input.action}' }; })();`;
4490
4869
  }
4491
4870
  }
4871
+ /**
4872
+ * Z-order: select the item, then call the matching app stacking method
4873
+ * (bringToFront / sendToBack / bringForward / sendBackward).
4874
+ */
4875
+ generateArrange(input) {
4876
+ const method = {
4877
+ front: 'bringToFront',
4878
+ back: 'sendToBack',
4879
+ forward: 'bringForward',
4880
+ backward: 'sendBackward',
4881
+ }[input.action];
4882
+ const id = JSON.stringify(input.itemId);
4883
+ return `
4884
+ // Arrange ${input.itemId} → ${input.action}
4885
+ (function() {
4886
+ const item = app.getItemById(${id});
4887
+ if (!item) return { success: false, error: 'Item not found: ' + ${id} };
4888
+ if (typeof app.${method} !== 'function') return { success: false, error: 'app.${method} unavailable — update FxTool' };
4889
+ app.select(${id});
4890
+ app.${method}();
4891
+ return { success: true, itemId: ${id}, action: '${input.action}' };
4892
+ })();`.trim();
4893
+ }
4894
+ /**
4895
+ * On-device object detection (FxTool ImageWorkflow). Finds objects in an image;
4896
+ * with asNodes promotes each detection to a typed, image-anchored design node
4897
+ * (pp:Detected*) instead of a labeled box. Async (runs an on-device ML model).
4898
+ */
4899
+ generateDetectObjects(input) {
4900
+ const args = JSON.stringify({
4901
+ ...(input.itemId !== undefined ? { itemId: input.itemId } : {}),
4902
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
4903
+ ...(input.asNodes !== undefined ? { asNodes: input.asNodes } : {}),
4904
+ ...(input.queries !== undefined ? { queries: input.queries } : {}),
4905
+ });
4906
+ return `
4907
+ // Detect objects${input.queries !== undefined ? ' (open-vocabulary / OWL-ViT)' : ''}${input.asNodes ? ' (asNodes — relational compositing)' : ''}
4908
+ (async function() {
4909
+ if (typeof app.detectObjects !== 'function') {
4910
+ return { success: false, error: 'app.detectObjects unavailable — update FxTool to a build that exposes on-device object detection on app' };
4911
+ }
4912
+ try {
4913
+ const res = await app.detectObjects(${args});
4914
+ return Object.assign({ success: !!(res && res.ok) }, res || {});
4915
+ } catch (e) { return { success: false, error: String((e && e.message) || e) }; }
4916
+ })();`.trim();
4917
+ }
4918
+ /**
4919
+ * Extract the best-matching detected region from an image as a NEW item (FxTool
4920
+ * ImageWorkflow.extractObject). Async (runs detection first).
4921
+ */
4922
+ generateExtractObject(input) {
4923
+ const args = JSON.stringify({
4924
+ ...(input.label !== undefined ? { label: input.label } : {}),
4925
+ ...(input.itemId !== undefined ? { itemId: input.itemId } : {}),
4926
+ ...(input.x !== undefined ? { x: input.x } : {}),
4927
+ ...(input.y !== undefined ? { y: input.y } : {}),
4928
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
4929
+ });
4930
+ return `
4931
+ // Extract object${input.label ? ` "${input.label}"` : ''} from the image
4932
+ (async function() {
4933
+ if (typeof app.extractObject !== 'function') {
4934
+ return { success: false, error: 'app.extractObject unavailable — update FxTool to a build that exposes on-device object detection on app' };
4935
+ }
4936
+ try {
4937
+ const res = await app.extractObject(${args});
4938
+ return Object.assign({ success: !!(res && res.ok) }, res || {});
4939
+ } catch (e) { return { success: false, error: String((e && e.message) || e) }; }
4940
+ })();`.trim();
4941
+ }
4942
+ /**
4943
+ * Camera director (FxTool DirectorCompiler): compile a shot list into ONE
4944
+ * camera_animates walkthrough. 'auto' derives a shot per item; 'shots' applies
4945
+ * an explicit list. WYSIWYG between editor and any export size (one camera).
4946
+ */
4947
+ generateCameraDirector(input) {
4948
+ if (input.action === 'shots') {
4949
+ const shotsJson = JSON.stringify(input.shots ?? []);
4950
+ const opts = JSON.stringify({ loop: !!input.loop });
4951
+ return `
4952
+ // Camera director — apply explicit shot list
4953
+ (function() {
4954
+ if (typeof app.applyDirectorShots !== 'function') {
4955
+ return { success: false, error: 'app.applyDirectorShots unavailable — update FxTool to a build with the camera director' };
4956
+ }
4957
+ const shots = ${shotsJson};
4958
+ const ok = app.applyDirectorShots(shots, ${opts});
4959
+ return { success: !!ok, mode: 'shots', shotCount: shots.length, loop: ${!!input.loop} };
4960
+ })();`.trim();
4961
+ }
4962
+ const opts = JSON.stringify({
4963
+ ...(input.order !== undefined ? { order: input.order } : {}),
4964
+ ...(input.hold !== undefined ? { hold: input.hold } : {}),
4965
+ ...(input.establishing !== undefined ? { establishing: input.establishing } : {}),
4966
+ loop: !!input.loop,
4967
+ });
4968
+ return `
4969
+ // Camera director — auto-direct a walkthrough of the scene
4970
+ (function() {
4971
+ if (typeof app.autoDirectStory !== 'function') {
4972
+ return { success: false, error: 'app.autoDirectStory unavailable — update FxTool to a build with the camera director' };
4973
+ }
4974
+ const shots = app.autoDirectStory(${opts});
4975
+ if (!shots) return { success: false, mode: 'auto', error: 'No items to direct — add content first' };
4976
+ return { success: true, mode: 'auto', shotCount: shots.length, shots: shots };
4977
+ })();`.trim();
4978
+ }
4979
+ /**
4980
+ * Inner JS that groups resolved item ids into ONE draggable entity via the
4981
+ * GroupManager (createGroup → addItemsToGroup). idsArrayExpr is a JS expression
4982
+ * evaluating to an array of id strings; nameExpr is a JS string expression or 'undefined'.
4983
+ * Shared by the standalone pinepaper_group tool and the batch "group" operation.
4984
+ */
4985
+ groupCodeBody(idsArrayExpr, nameExpr) {
4986
+ return `
4987
+ if (!app.groupManager) return { success: false, error: 'GroupManager not available — update FxTool' };
4988
+ const ids = (${idsArrayExpr}).filter(function(x) { return x != null; });
4989
+ const items = ids.map(function(id) { return app.getItemById(id); }).filter(Boolean);
4990
+ if (!items.length) return { success: false, error: 'No valid items to group (check the ids / $N refs)' };
4991
+ const group = app.groupManager.createGroup(${nameExpr});
4992
+ if (!group) return { success: false, error: 'Failed to create group' };
4993
+ app.groupManager.addItemsToGroup(items, group);
4994
+ if (app.historyManager) app.historyManager.saveState();
4995
+ const groupId = (group.data && (group.data.registryId || group.data.groupId)) || null;
4996
+ return { success: true, groupId: groupId, groupName: group.data && group.data.groupName, itemCount: items.length };`;
4997
+ }
4998
+ /**
4999
+ * Group a set of items into one draggable entity, or ungroup one back into loose
5000
+ * items. Grouping is non-destructive — ungroup restores the individual items.
5001
+ */
5002
+ generateGroup(input) {
5003
+ if (input.action === 'break_apart') {
5004
+ const idExpr = JSON.stringify(input.itemId || '');
5005
+ return `
5006
+ // Break apart ${input.itemId || ''} into movable parts
5007
+ (function() {
5008
+ if (typeof app.breakApart !== 'function') return { success: false, error: 'app.breakApart unavailable — update FxTool to a build with SVG/group segmentation' };
5009
+ const target = app.getItemById(${idExpr});
5010
+ if (!target) return { success: false, error: 'Item not found: ' + ${idExpr} };
5011
+ const result = app.breakApart(target);
5012
+ if (!result || !result.parts || !result.parts.length) return { success: false, error: 'Nothing to break apart (no sub-parts found)' };
5013
+ const partIds = result.parts.map(function(p) { return p && p.data && (p.data.registryId || p.data.id); }).filter(Boolean);
5014
+ const groupId = (result.group && result.group.data && (result.group.data.registryId || result.group.data.groupId)) || null;
5015
+ return { success: true, action: 'break_apart', groupId: groupId, partIds: partIds, partCount: partIds.length };
5016
+ })();`.trim();
5017
+ }
5018
+ if (input.action === 'ungroup') {
5019
+ const idExpr = JSON.stringify(input.groupId || '');
5020
+ return `
5021
+ // Ungroup ${input.groupId || ''}
5022
+ (function() {
5023
+ if (!app.groupManager) return { success: false, error: 'GroupManager not available — update FxTool' };
5024
+ const gid = ${idExpr};
5025
+ const group = app.getItemById(gid) || (app.groupManager.getGroupById ? app.groupManager.getGroupById(gid) : null);
5026
+ if (!group) return { success: false, error: 'Group not found: ' + gid };
5027
+ app.groupManager.ungroupAll(group);
5028
+ if (app.historyManager) app.historyManager.saveState();
5029
+ return { success: true, ungrouped: true, groupId: gid };
5030
+ })();`.trim();
5031
+ }
5032
+ const idsArrayExpr = JSON.stringify(input.itemIds ?? []);
5033
+ const nameExpr = input.groupName !== undefined ? JSON.stringify(input.groupName) : 'undefined';
5034
+ return `
5035
+ // Group ${(input.itemIds || []).length} items${input.groupName ? ` as "${input.groupName}"` : ''}
5036
+ (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
5037
+ })();`.trim();
5038
+ }
5039
+ /**
5040
+ * Audit the live scene (or pre-validate a batch of proposed ops) with FxTool's
5041
+ * OntologyValidator — structured { ok, diagnostics } feedback instead of console warnings.
5042
+ */
5043
+ generateValidateScene(input) {
5044
+ const guard = `const v = app.sceneValidator;
5045
+ if (!v) return { success: false, error: 'app.sceneValidator unavailable — update FxTool to a build with the OntologyValidator' };`;
5046
+ if (input.ops) {
5047
+ const opsJson = JSON.stringify(input.ops);
5048
+ return `
5049
+ // Pre-validate proposed ops against the live scene
5050
+ (function() {
5051
+ ${guard}
5052
+ if (typeof v.validateOps !== 'function') return { success: false, error: 'validateOps unavailable in this FxTool build' };
5053
+ const r = v.validateOps(${opsJson});
5054
+ return { success: true, mode: 'ops', ok: r.ok, diagnosticCount: r.diagnostics.length, diagnostics: r.diagnostics };
5055
+ })();`.trim();
5056
+ }
5057
+ return `
5058
+ // Audit the current scene
5059
+ (function() {
5060
+ ${guard}
5061
+ const r = v.validateScene();
5062
+ return { success: true, mode: 'scene', ok: r.ok, diagnosticCount: r.diagnostics.length, diagnostics: r.diagnostics };
5063
+ })();`.trim();
5064
+ }
5065
+ /**
5066
+ * Deterministic headless frame capture (S3): app.captureFramesAt(times, { seed })
5067
+ * seeds Math.random once around the sequence and evaluates the scene at each time via
5068
+ * sceneAt(t). Returns a cheap per-frame hash by default (token-light) so an agent can
5069
+ * verify determinism (re-run → identical hashes) and whether frames actually change.
5070
+ */
5071
+ generateCaptureFrames(input) {
5072
+ const timesJson = JSON.stringify(input.times);
5073
+ const seed = input.seed !== undefined ? input.seed : 0;
5074
+ const includeDataUrls = !!input.includeDataUrls;
5075
+ return `
5076
+ // Deterministic frame capture
5077
+ (function() {
5078
+ if (typeof app.captureFramesAt !== 'function') {
5079
+ return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
5080
+ }
5081
+ const includeDataUrls = ${includeDataUrls};
5082
+ const hashStr = function(s) { let h = 5381; for (let i = 0; i < s.length; i++) { h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; } return h.toString(16); };
5083
+ const frames = app.captureFramesAt(${timesJson}, {
5084
+ seed: ${seed},
5085
+ capture: function(c, t, i) {
5086
+ // Prefer app.captureFrameDataURL(): it renders only the used region, so an
5087
+ // unbounded canvas's fill-workspace margin is never baked into the frame.
5088
+ // Fall back to the raw canvas toDataURL on pre-capture-region FxTool builds.
5089
+ const url = (typeof app.captureFrameDataURL === 'function')
5090
+ ? app.captureFrameDataURL()
5091
+ : ((c && c.toDataURL) ? c.toDataURL() : '');
5092
+ const f = { index: i, time: t, hash: hashStr(url), bytes: url.length };
5093
+ if (includeDataUrls) f.dataUrl = url;
5094
+ return f;
5095
+ }
5096
+ });
5097
+ const uniqueHashes = new Set(frames.map(function(f) { return f.hash; })).size;
5098
+ return { success: true, seed: ${seed}, frameCount: frames.length, uniqueHashes: uniqueHashes, allIdentical: uniqueHashes <= 1, frames: frames };
5099
+ })();`.trim();
5100
+ }
5101
+ /**
5102
+ * Ontology→scene compiler (S12-E3). Compiles the JSON-LD design graph SERVER-SIDE
5103
+ * (deterministic, unit-tested) into create/addRelation ops, then emits code that
5104
+ * applies them. Only root nodes carry coordinates; structural relations place the
5105
+ * rest at runtime. Compile diagnostics are baked into the emitted result.
5106
+ */
5107
+ generateInstantiateOntology(input) {
5108
+ const { ops, diagnostics } = new OntologyCompiler({
5109
+ canvas: input.canvas,
5110
+ defaultGeometry: input.defaultGeometry,
5111
+ }).compile(input.doc);
5112
+ const opsJson = JSON.stringify(ops, null, 2);
5113
+ const diagJson = JSON.stringify(diagnostics);
5114
+ return `
5115
+ // Instantiate ontology → scene (${ops.filter((o) => o.op === 'create').length} items, ${ops.filter((o) => o.op === 'addRelation').length} relations)
5116
+ (function() {
5117
+ if (typeof app.create !== 'function' || typeof app.addRelation !== 'function') {
5118
+ return { success: false, error: 'app.create / app.addRelation unavailable' };
5119
+ }
5120
+ const ops = ${opsJson};
5121
+ const diagnostics = ${diagJson};
5122
+ const itemIds = [];
5123
+ const errors = [];
5124
+ for (const op of ops) {
5125
+ try {
5126
+ if (op.op === 'create') {
5127
+ app.create(op.type, { id: op.id, x: op.x, y: op.y, width: op.width, height: op.height });
5128
+ itemIds.push(op.id);
5129
+ } else if (op.op === 'addRelation') {
5130
+ app.addRelation(op.from, op.to, op.relation, op.params || {});
5131
+ }
5132
+ } catch (e) {
5133
+ errors.push({ op: op.op, id: op.id || (op.from + '->' + op.to), error: e && e.message });
5134
+ }
5135
+ }
5136
+ if (app.historyManager) app.historyManager.saveState();
5137
+ return { success: errors.length === 0, itemIds: itemIds, itemCount: itemIds.length, diagnostics: diagnostics, errors: errors };
5138
+ })();`.trim();
5139
+ }
5140
+ /**
5141
+ * Relational-density lint of the live scene (S12-E2) via app.lintScene(). Reports
5142
+ * relationalDensity + edge/component counts and suggests structural relations for
5143
+ * unrelated item pairs already in a structural configuration. Read-only/advisory.
5144
+ */
5145
+ generateLintScene(input) {
5146
+ const optsJson = JSON.stringify({
5147
+ ...(input.eps !== undefined ? { eps: input.eps } : {}),
5148
+ ...(input.cap !== undefined ? { cap: input.cap } : {}),
5149
+ });
5150
+ return `
5151
+ // Relational-density lint (advisory)
5152
+ (function() {
5153
+ if (typeof app.lintScene !== 'function') {
5154
+ return { success: false, error: 'app.lintScene unavailable — update FxTool to a build with the relational linter (S12-E2)' };
5155
+ }
5156
+ const result = app.lintScene(${optsJson});
5157
+ return { success: true, density: result.density, suggestions: result.suggestions };
5158
+ })();`.trim();
5159
+ }
5160
+ /**
5161
+ * Media (video/audio) via window.PinePaperAgent. Upload is URL-based (the agent
5162
+ * hands a URL, FxTool fetches → File). Async IIFE so uploads resolve before the
5163
+ * value is returned (the governor awaits it).
5164
+ */
5165
+ generateMedia(input) {
5166
+ const guard = ` const A = (typeof window !== 'undefined') && window.PinePaperAgent;
5167
+ if (!A || typeof A.uploadVideo !== 'function') { return { success: false, error: 'window.PinePaperAgent media API unavailable — update FxTool to a media-capable build' }; }`;
5168
+ switch (input.action) {
5169
+ case 'upload_video': {
5170
+ const opts = JSON.stringify({
5171
+ ...(input.position ? { position: input.position } : {}),
5172
+ ...(input.scale !== undefined ? { scale: input.scale } : {}),
5173
+ ...(input.timeOffset !== undefined ? { timeOffset: input.timeOffset } : {}),
5174
+ ...(input.clipInPoint !== undefined ? { clipInPoint: input.clipInPoint } : {}),
5175
+ ...(input.clipOutPoint !== undefined ? { clipOutPoint: input.clipOutPoint } : {}),
5176
+ });
5177
+ return `
5178
+ // Upload video from URL
5179
+ (async function() {
5180
+ ${guard}
5181
+ const info = await A.uploadVideo(${JSON.stringify(input.url)}, ${opts});
5182
+ return { success: true, action: 'upload_video', media: info };
5183
+ })();`.trim();
5184
+ }
5185
+ case 'upload_audio': {
5186
+ const opts = JSON.stringify({
5187
+ ...(input.volume !== undefined ? { volume: input.volume } : {}),
5188
+ ...(input.loop !== undefined ? { loop: input.loop } : {}),
5189
+ ...(input.muted !== undefined ? { muted: input.muted } : {}),
5190
+ ...(input.timeOffset !== undefined ? { timeOffset: input.timeOffset } : {}),
5191
+ });
5192
+ return `
5193
+ // Upload audio from URL
5194
+ (async function() {
5195
+ ${guard}
5196
+ const info = await A.uploadAudio(${JSON.stringify(input.url)}, ${opts});
5197
+ return { success: true, action: 'upload_audio', media: info };
5198
+ })();`.trim();
5199
+ }
5200
+ case 'list':
5201
+ return `
5202
+ // List media
5203
+ (function() {
5204
+ ${guard}
5205
+ return { success: true, action: 'list', media: A.listMedia() };
5206
+ })();`.trim();
5207
+ case 'remove':
5208
+ return `
5209
+ // Remove media
5210
+ (function() {
5211
+ ${guard}
5212
+ const removed = A.removeMedia(${JSON.stringify(input.id)});
5213
+ return { success: removed, action: 'remove', id: ${JSON.stringify(input.id)}, removed: removed };
5214
+ })();`.trim();
5215
+ case 'set_playback_rate':
5216
+ return `
5217
+ // Set media playback rate
5218
+ (function() {
5219
+ ${guard}
5220
+ const ok = A.setMediaPlaybackRate(${JSON.stringify(input.id)}, ${input.rate});
5221
+ return { success: ok, action: 'set_playback_rate', id: ${JSON.stringify(input.id)}, rate: ${input.rate} };
5222
+ })();`.trim();
5223
+ case 'set_clip':
5224
+ return `
5225
+ // Re-trim an existing media clip (media-time seconds)
5226
+ (function() {
5227
+ ${guard}
5228
+ if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
5229
+ const ok = A.setMediaClip(${JSON.stringify(input.id)}, ${input.inPoint}, ${input.outPoint});
5230
+ return { success: ok, action: 'set_clip', id: ${JSON.stringify(input.id)}, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} };
5231
+ })();`.trim();
5232
+ }
5233
+ }
5234
+ /**
5235
+ * One-shot image crop via the PinePaper.cropImage facade (Track A parity).
5236
+ * The replacement raster keeps the item's registry id.
5237
+ */
5238
+ generateCropImage(input) {
5239
+ const opts = input.aspectRatio ? JSON.stringify({ aspectRatio: input.aspectRatio }) : '{}';
5240
+ return `
5241
+ // Crop image ${input.itemId}
5242
+ (async function() {
5243
+ if (typeof app.cropImage !== 'function') { return { success: false, error: 'app.cropImage unavailable — update FxTool to a build with one-shot image ops' }; }
5244
+ const result = await app.cropImage(${JSON.stringify(input.itemId)}, ${JSON.stringify(input.rect)}, ${opts});
5245
+ if (!result) { return { success: false, error: 'crop failed — item is not a raster or rect does not intersect it' }; }
5246
+ return { success: true, itemId: result.data.id, width: Math.round(result.width), height: Math.round(result.height) };
5247
+ })();`.trim();
5248
+ }
5249
+ /**
5250
+ * One-shot chroma key (background removal) via PinePaper.applyChromaKey.
5251
+ * Threshold/smoothing auto-estimate when omitted.
5252
+ */
5253
+ generateChromaKey(input) {
5254
+ const opts = JSON.stringify({
5255
+ ...(input.color !== undefined ? { color: input.color } : {}),
5256
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
5257
+ ...(input.smoothing !== undefined ? { smoothing: input.smoothing } : {}),
5258
+ });
5259
+ return `
5260
+ // Chroma-key image ${input.itemId}
5261
+ (async function() {
5262
+ if (typeof app.applyChromaKey !== 'function') { return { success: false, error: 'app.applyChromaKey unavailable — update FxTool to a build with one-shot image ops' }; }
5263
+ const result = await app.applyChromaKey(${JSON.stringify(input.itemId)}, ${opts});
5264
+ if (!result) { return { success: false, error: 'chroma key failed — item is not a raster or the color is invalid' }; }
5265
+ return { success: true, itemId: result.data.id, params: result.data.chromaKeyParams };
5266
+ })();`.trim();
5267
+ }
5268
+ /**
5269
+ * Rigging (skeletons/bones/IK/breakdown-pose keyframes) via app.riggingSystem.
5270
+ * One consolidated emitter; each action maps 1:1 to a riggingSystem method with
5271
+ * FxTool's exact param contract. Guarded on app.riggingSystem.
5272
+ */
5273
+ generateRigging(input) {
5274
+ const g = ` const R = app.riggingSystem;
5275
+ if (!R) { return { success: false, error: 'app.riggingSystem unavailable — update FxTool to a rigging-capable build' }; }`;
5276
+ const wrap = (comment, body) => `\n// ${comment}\n(function() {\n${g}\n${body}\n})();`.trim();
5277
+ const S = (v) => JSON.stringify(v);
5278
+ switch (input.action) {
5279
+ case 'create_skeleton': {
5280
+ const root = input.rootPosition ? S(input.rootPosition) : S({ x: 400, y: 300 });
5281
+ return wrap('Rigging: create skeleton', ` const skeletonId = R.createSkeleton(${S(input.name ?? null)}, ${root});
5282
+ if (app.historyManager) app.historyManager.saveState();
5283
+ return { success: !!skeletonId, action: 'create_skeleton', skeletonId: skeletonId };`);
5284
+ }
5285
+ case 'add_bone': {
5286
+ const config = S({
5287
+ ...(input.name !== undefined ? { name: input.name } : {}),
5288
+ ...(input.parentBoneId !== undefined ? { parentBoneId: input.parentBoneId } : {}),
5289
+ ...(input.length !== undefined ? { length: input.length } : {}),
5290
+ ...(input.angle !== undefined ? { angle: input.angle } : {}),
5291
+ ...(input.flexibility !== undefined ? { flexibility: input.flexibility } : {}),
5292
+ ...(input.segments !== undefined ? { segments: input.segments } : {}),
5293
+ });
5294
+ return wrap('Rigging: add bone', ` const boneId = R.addBone(${S(input.skeletonId)}, ${config});
5295
+ if (app.historyManager) app.historyManager.saveState();
5296
+ return { success: !!boneId, action: 'add_bone', boneId: boneId };`);
5297
+ }
5298
+ case 'attach_item': {
5299
+ const opts = S(input.attachPoint !== undefined ? { attachPoint: input.attachPoint } : {});
5300
+ return wrap('Rigging: attach item to bone', ` const ok = R.attachItem(${S(input.skeletonId)}, ${S(input.boneId)}, ${S(input.itemId)}, ${opts});
5301
+ if (app.historyManager) app.historyManager.saveState();
5302
+ return { success: !!ok, action: 'attach_item', itemId: ${S(input.itemId)}, boneId: ${S(input.boneId)} };`);
5303
+ }
5304
+ case 'create_ik_chain': {
5305
+ const config = S({
5306
+ boneIds: input.boneIds,
5307
+ ...(input.name !== undefined ? { name: input.name } : {}),
5308
+ ...(input.solverType !== undefined ? { solverType: input.solverType } : {}),
5309
+ ...(input.iterations !== undefined ? { iterations: input.iterations } : {}),
5310
+ ...(input.tolerance !== undefined ? { tolerance: input.tolerance } : {}),
5311
+ ...(input.strength !== undefined ? { strength: input.strength } : {}),
5312
+ ...(input.poleVector !== undefined ? { poleVector: input.poleVector } : {}),
5313
+ });
5314
+ return wrap('Rigging: create IK chain', ` const chainId = R.createIKChain(${S(input.skeletonId)}, ${config});
5315
+ if (app.historyManager) app.historyManager.saveState();
5316
+ return { success: !!chainId, action: 'create_ik_chain', chainId: chainId };`);
5317
+ }
5318
+ case 'add_pose_keyframe': {
5319
+ const opts = S({
5320
+ ...(input.favor !== undefined ? { favor: input.favor } : {}),
5321
+ ...(input.breakdown !== undefined ? { breakdown: input.breakdown } : {}),
5322
+ ...(input.curve !== undefined ? { curve: input.curve } : {}),
5323
+ ...(input.boneOffsets !== undefined ? { boneOffsets: input.boneOffsets } : {}),
5324
+ ...(input.movingHold !== undefined ? { movingHold: input.movingHold } : {}),
5325
+ ...(input.holdDrift !== undefined ? { holdDrift: input.holdDrift } : {}),
5326
+ });
5327
+ return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${S(input.pose)}, ${S(input.easing ?? 'linear')}, ${opts});
5328
+ if (app.historyManager) app.historyManager.saveState();
5329
+ return { success: !!ok, action: 'add_pose_keyframe', time: ${input.time} };`);
5330
+ }
5331
+ case 'set_target_path': {
5332
+ const opts = S({
5333
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
5334
+ ...(input.loop !== undefined ? { loop: input.loop } : {}),
5335
+ });
5336
+ return wrap('Rigging: set IK target path', ` const ok = R.setTargetPath(${S(input.skeletonId)}, ${S(input.chainId)}, ${S(input.waypoints)}, ${opts});
5337
+ if (app.historyManager) app.historyManager.saveState();
5338
+ return { success: !!ok, action: 'set_target_path', chainId: ${S(input.chainId)} };`);
5339
+ }
5340
+ case 'save_pose':
5341
+ return wrap('Rigging: save pose', ` const poseId = R.savePose(${S(input.skeletonId)}, ${S(input.name ?? null)});
5342
+ if (app.historyManager) app.historyManager.saveState();
5343
+ return { success: !!poseId, action: 'save_pose', poseId: poseId };`);
5344
+ case 'save_shape_key':
5345
+ return wrap('Rigging: save shape key', ` const shapeKeyId = R.saveShapeKey(${S(input.skeletonId)}, ${S(input.name ?? null)});
5346
+ if (app.historyManager) app.historyManager.saveState();
5347
+ return { success: !!shapeKeyId, action: 'save_shape_key', shapeKeyId: shapeKeyId };`);
5348
+ }
5349
+ }
5350
+ /**
5351
+ * Drive app.constructionSequence (Layer 3): build a step-by-step reveal of a
5352
+ * figure, play it on the timeline, clear it, or list sequences. Item refs are
5353
+ * registryId strings — build resolves them via app.getItemById.
5354
+ */
5355
+ generateConstructionSequence(input) {
5356
+ const guard = `const cs = app.constructionSequence;
5357
+ if (!cs) return { success: false, error: 'app.constructionSequence unavailable — update FxTool to a build with the construction-sequence library' };`;
5358
+ switch (input.action) {
5359
+ case 'build': {
5360
+ const stepsJson = JSON.stringify(input.steps ?? []);
5361
+ const opts = JSON.stringify({
5362
+ ...(input.stepDuration !== undefined ? { stepDuration: input.stepDuration } : {}),
5363
+ ...(input.fadeIn !== undefined ? { fadeIn: input.fadeIn } : {}),
5364
+ });
5365
+ return `
5366
+ // Build construction sequence
5367
+ (function() {
5368
+ ${guard}
5369
+ const rec = cs.build(${stepsJson}, ${opts});
5370
+ return {
5371
+ success: true,
5372
+ action: 'build',
5373
+ sequenceId: rec.id,
5374
+ stepCount: rec.steps.length,
5375
+ stepDuration: rec.stepDuration,
5376
+ fadeIn: rec.fadeIn,
5377
+ totalDuration: rec.totalDuration,
5378
+ };
5379
+ })();`.trim();
5380
+ }
5381
+ case 'play': {
5382
+ const seqArg = input.sequenceId ? JSON.stringify(input.sequenceId) : 'null';
5383
+ const opts = JSON.stringify({
5384
+ loop: !!input.loop,
5385
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
5386
+ });
5387
+ return `
5388
+ // Play construction sequence
5389
+ (function() {
5390
+ ${guard}
5391
+ let seqId = ${seqArg};
5392
+ if (!seqId) { const all = cs.list(); seqId = all.length ? all[all.length - 1].id : null; }
5393
+ if (!seqId) return { success: false, error: 'No sequence to play — build one first' };
5394
+ const duration = cs.play(seqId, ${opts});
5395
+ return { success: true, action: 'play', sequenceId: seqId, duration: duration, loop: ${!!input.loop} };
5396
+ })();`.trim();
5397
+ }
5398
+ case 'clear': {
5399
+ const seqArg = input.sequenceId ? JSON.stringify(input.sequenceId) : 'null';
5400
+ return `
5401
+ // Clear construction sequence
5402
+ (function() {
5403
+ ${guard}
5404
+ let seqId = ${seqArg};
5405
+ if (!seqId) { const all = cs.list(); seqId = all.length ? all[all.length - 1].id : null; }
5406
+ if (!seqId) return { success: false, error: 'No sequence to clear' };
5407
+ const ok = cs.clear(seqId);
5408
+ return { success: ok, action: 'clear', sequenceId: seqId };
5409
+ })();`.trim();
5410
+ }
5411
+ case 'list':
5412
+ default:
5413
+ return `
5414
+ // List construction sequences
5415
+ (function() {
5416
+ ${guard}
5417
+ const sequences = cs.list().map(function(r) {
5418
+ return { id: r.id, stepCount: r.steps.length, stepDuration: r.stepDuration, totalDuration: r.totalDuration };
5419
+ });
5420
+ return { success: true, action: 'list', sequences: sequences };
5421
+ })();`.trim();
5422
+ }
5423
+ }
5424
+ /**
5425
+ * Run an app.geometry.* construction helper (Layer 1) and, when createAs is
5426
+ * given, build a canvas item from the result. The op name is whitelisted by the
5427
+ * Zod enum, so it is safe to index app.geometry[op] directly. Mirrors FxTool's
5428
+ * documented composition: app.create('polygon', { points: app.geometry.regularPolygon(...) }).
5429
+ */
5430
+ generateGeometry(input) {
5431
+ const op = JSON.stringify(input.operation);
5432
+ const argsJson = JSON.stringify(input.args ?? []);
5433
+ const createAsJson = input.createAs ? JSON.stringify(input.createAs) : 'null';
5434
+ return `
5435
+ // Geometry construction: ${input.operation}
5436
+ (function() {
5437
+ const g = app.geometry;
5438
+ if (!g || typeof g[${op}] !== 'function') {
5439
+ return { success: false, error: 'app.geometry.' + ${op} + ' unavailable — update FxTool to a build with the geometry construction library' };
5440
+ }
5441
+ const result = g[${op}](...${argsJson});
5442
+ if (result == null) {
5443
+ return { success: false, operation: ${op}, result: null, error: 'Degenerate construction (parallel lines, collinear points, or a point inside the circle) — returned null' };
5444
+ }
5445
+
5446
+ let itemId = null;
5447
+ const createAs = ${createAsJson};
5448
+ if (createAs) {
5449
+ const { itemType, radius, ...style } = createAs;
5450
+ let item = null;
5451
+ if (Array.isArray(result)) {
5452
+ // Vertex list (regularPolygon / star / polygonFromVertices / tangent points)
5453
+ item = app.create('polygon', { points: result, ...style });
5454
+ } else if (result.center && typeof result.radius === 'number') {
5455
+ // Circle / circumcircle
5456
+ item = app.create('circle', { x: result.center.x, y: result.center.y, radius: result.radius, ...style });
5457
+ } else if (typeof result.x === 'number' && typeof result.y === 'number') {
5458
+ // Point → small marker
5459
+ item = app.create(itemType || 'circle', { x: result.x, y: result.y, radius: (radius != null ? radius : 6), ...style });
5460
+ } else {
5461
+ return { success: true, operation: ${op}, result, itemId: null, note: 'Result is not directly creatable (line/scalar) — use the returned geometry as input to other tools' };
5462
+ }
5463
+ if (item) {
5464
+ if (item.bringToFront) item.bringToFront();
5465
+ itemId = item.data && item.data.registryId;
5466
+ if (app.historyManager) app.historyManager.saveState();
5467
+ }
5468
+ }
5469
+
5470
+ return { success: true, operation: ${op}, result, itemId };
5471
+ })();`.trim();
5472
+ }
5473
+ /**
5474
+ * Generate code for an equation-driven path (Expression IR — S10 B5).
5475
+ * Emits app.createEquationPath(opts); the whole validated input IS the opts
5476
+ * object. Guards on the app method so old FxTool builds degrade gracefully.
5477
+ */
5478
+ generateEquationPath(input) {
5479
+ const optsJson = JSON.stringify(input);
5480
+ const kindJson = JSON.stringify(input.kind);
5481
+ return `
5482
+ // Equation-driven path: ${input.kind}${input.preset ? ' ' + input.preset : ''}
5483
+ (function() {
5484
+ if (typeof app.createEquationPath !== 'function') {
5485
+ return { success: false, error: 'app.createEquationPath unavailable — update FxTool to a build with the Expression IR equation-path API (S10 B5)' };
5486
+ }
5487
+ const path = app.createEquationPath(${optsJson});
5488
+ if (!path) {
5489
+ return { success: false, error: 'Equation path produced no points — check expr/xExpr/yExpr, the sample range, or the preset name' };
5490
+ }
5491
+ const itemId = path.data && path.data.registryId;
5492
+ if (app.historyManager) app.historyManager.saveState();
5493
+ return { success: true, kind: ${kindJson}, itemId };
5494
+ })();`.trim();
5495
+ }
5496
+ /**
5497
+ * Generate code for the pp:event channel (S11 event-driven scene chains).
5498
+ * create → app.createEvent(name, opts) → eventId; pulse → app.pulseEvent.
5499
+ * Guards on the app methods so old FxTool builds degrade gracefully.
5500
+ */
5501
+ generateEvent(input) {
5502
+ if (input.action === 'create') {
5503
+ const nameJson = JSON.stringify(input.name);
5504
+ const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y });
5505
+ return `
5506
+ // Create event: ${input.name}
5507
+ (function() {
5508
+ if (typeof app.createEvent !== 'function') {
5509
+ return { success: false, error: 'app.createEvent unavailable — update FxTool to a build with the event system (S11)' };
5510
+ }
5511
+ const eventId = app.createEvent(${nameJson}, ${optsJson});
5512
+ if (!eventId) { return { success: false, error: 'Event creation failed' }; }
5513
+ if (app.historyManager) app.historyManager.saveState();
5514
+ return { success: true, action: 'create', eventId };
5515
+ })();`.trim();
5516
+ }
5517
+ // pulse — a runtime fire, not a scene edit (no history save)
5518
+ const eventIdJson = JSON.stringify(input.eventId);
5519
+ const payloadJson = JSON.stringify(input.payload ?? null);
5520
+ return `
5521
+ // Pulse event: ${input.eventId}
5522
+ (function() {
5523
+ if (typeof app.pulseEvent !== 'function') {
5524
+ return { success: false, error: 'app.pulseEvent unavailable — update FxTool to a build with the event system (S11)' };
5525
+ }
5526
+ app.pulseEvent(${eventIdJson}, ${payloadJson});
5527
+ return { success: true, action: 'pulse', eventId: ${eventIdJson} };
5528
+ })();`.trim();
5529
+ }
4492
5530
  }
4493
5531
  // Export singleton instance
4494
5532
  export const codeGenerator = new PinePaperCodeGenerator();