@pinepaper.studio/mcp-server 1.6.18 → 1.6.20

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 (127) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +179 -10
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/agent/export-manager.js +2 -2
  11. package/dist/agent/session-manager.d.ts +8 -0
  12. package/dist/agent/session-manager.d.ts.map +1 -1
  13. package/dist/agent/session-manager.js +17 -2
  14. package/dist/agent/session-manager.js.map +1 -1
  15. package/dist/assets/asset-manager.d.ts +28 -1
  16. package/dist/assets/asset-manager.d.ts.map +1 -1
  17. package/dist/assets/asset-manager.js +65 -25
  18. package/dist/assets/asset-manager.js.map +1 -1
  19. package/dist/assets/index.d.ts +1 -3
  20. package/dist/assets/index.d.ts.map +1 -1
  21. package/dist/assets/index.js +1 -3
  22. package/dist/assets/index.js.map +1 -1
  23. package/dist/assets/repositories/iconify.js +1 -1
  24. package/dist/assets/repositories/iconify.js.map +1 -1
  25. package/dist/assets/repositories/openclipart.js +1 -1
  26. package/dist/assets/repositories/openclipart.js.map +1 -1
  27. package/dist/assets/repositories/svgrepo.js +2 -2
  28. package/dist/assets/repositories/svgrepo.js.map +1 -1
  29. package/dist/assets/types.d.ts +2 -2
  30. package/dist/assets/types.d.ts.map +1 -1
  31. package/dist/browser/puppeteer-controller.d.ts +25 -0
  32. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  33. package/dist/browser/puppeteer-controller.js +107 -1
  34. package/dist/browser/puppeteer-controller.js.map +1 -1
  35. package/dist/build-info.json +3 -3
  36. package/dist/cli.js +4 -2
  37. package/dist/cli.js.map +1 -1
  38. package/dist/design/design-systems.d.ts +2 -0
  39. package/dist/design/design-systems.d.ts.map +1 -1
  40. package/dist/design/design-systems.js +2 -0
  41. package/dist/design/design-systems.js.map +1 -1
  42. package/dist/execution/canvas-state.d.ts.map +1 -1
  43. package/dist/execution/canvas-state.js +19 -43
  44. package/dist/execution/canvas-state.js.map +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +27 -11
  47. package/dist/index.js.map +1 -1
  48. package/dist/ontology/design-graph.js +2 -2
  49. package/dist/ontology/design-graph.js.map +1 -1
  50. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  51. package/dist/ontology/upstream-vocabulary.js +2 -2
  52. package/dist/ontology/validator.js +1 -1
  53. package/dist/ontology/validator.js.map +1 -1
  54. package/dist/ontology/vocabulary.d.ts.map +1 -1
  55. package/dist/ontology/vocabulary.js +8 -0
  56. package/dist/ontology/vocabulary.js.map +1 -1
  57. package/dist/tools/choreograph.d.ts +64 -0
  58. package/dist/tools/choreograph.d.ts.map +1 -0
  59. package/dist/tools/choreograph.js +336 -0
  60. package/dist/tools/choreograph.js.map +1 -0
  61. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  62. package/dist/tools/compact-descriptions.js +16 -15
  63. package/dist/tools/compact-descriptions.js.map +1 -1
  64. package/dist/tools/definitions.d.ts +10 -1
  65. package/dist/tools/definitions.d.ts.map +1 -1
  66. package/dist/tools/definitions.js +705 -150
  67. package/dist/tools/definitions.js.map +1 -1
  68. package/dist/tools/effect-check.d.ts +26 -0
  69. package/dist/tools/effect-check.d.ts.map +1 -0
  70. package/dist/tools/effect-check.js +129 -0
  71. package/dist/tools/effect-check.js.map +1 -0
  72. package/dist/tools/effect-class.d.ts +31 -0
  73. package/dist/tools/effect-class.d.ts.map +1 -0
  74. package/dist/tools/effect-class.js +170 -0
  75. package/dist/tools/effect-class.js.map +1 -0
  76. package/dist/tools/engine-lists.d.ts +20 -5
  77. package/dist/tools/engine-lists.d.ts.map +1 -1
  78. package/dist/tools/engine-lists.js +106 -4
  79. package/dist/tools/engine-lists.js.map +1 -1
  80. package/dist/tools/engine-methods.d.ts.map +1 -1
  81. package/dist/tools/engine-methods.js +22 -1
  82. package/dist/tools/engine-methods.js.map +1 -1
  83. package/dist/tools/engine-surface.d.ts +1 -1
  84. package/dist/tools/engine-surface.d.ts.map +1 -1
  85. package/dist/tools/engine-surface.js +232 -13
  86. package/dist/tools/engine-surface.js.map +1 -1
  87. package/dist/tools/handlers/camera.js +8 -8
  88. package/dist/tools/handlers/camera.js.map +1 -1
  89. package/dist/tools/handlers/export.d.ts.map +1 -1
  90. package/dist/tools/handlers/export.js +84 -5
  91. package/dist/tools/handlers/export.js.map +1 -1
  92. package/dist/tools/handlers/font.js +10 -10
  93. package/dist/tools/handlers/font.js.map +1 -1
  94. package/dist/tools/handlers/generate.d.ts +12 -12
  95. package/dist/tools/handlers/maps.d.ts.map +1 -1
  96. package/dist/tools/handlers/maps.js +23 -3
  97. package/dist/tools/handlers/maps.js.map +1 -1
  98. package/dist/tools/handlers.d.ts +44 -0
  99. package/dist/tools/handlers.d.ts.map +1 -1
  100. package/dist/tools/handlers.js +413 -39
  101. package/dist/tools/handlers.js.map +1 -1
  102. package/dist/tools/index.d.ts +2 -2
  103. package/dist/tools/index.d.ts.map +1 -1
  104. package/dist/tools/index.js +2 -2
  105. package/dist/tools/index.js.map +1 -1
  106. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  107. package/dist/tools/minimal-descriptions.js +39 -26
  108. package/dist/tools/minimal-descriptions.js.map +1 -1
  109. package/dist/tools/shape-params.d.ts +2 -2
  110. package/dist/tools/shape-params.d.ts.map +1 -1
  111. package/dist/tools/shape-params.js +27 -2
  112. package/dist/tools/shape-params.js.map +1 -1
  113. package/dist/tools/stick-vocabulary.d.ts +1 -1
  114. package/dist/tools/stick-vocabulary.js +1 -1
  115. package/dist/tools/toolkits.d.ts.map +1 -1
  116. package/dist/tools/toolkits.js +11 -0
  117. package/dist/tools/toolkits.js.map +1 -1
  118. package/dist/types/code-generator.d.ts +91 -3
  119. package/dist/types/code-generator.d.ts.map +1 -1
  120. package/dist/types/code-generator.js +1544 -276
  121. package/dist/types/code-generator.js.map +1 -1
  122. package/dist/types/schemas.d.ts +3203 -907
  123. package/dist/types/schemas.d.ts.map +1 -1
  124. package/dist/types/schemas.js +393 -53
  125. package/dist/types/schemas.js.map +1 -1
  126. package/manifest.json +46 -2
  127. package/package.json +4 -3
@@ -4,12 +4,13 @@
4
4
  * Converts MCP tool inputs to executable PinePaper JavaScript code.
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
+ import { compileChoreography } from '../tools/choreograph.js';
7
8
  import { normalizeSvgSize } from '../utils/svg-size.js';
8
9
  import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
9
10
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
10
11
  import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
11
12
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
12
- import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
13
+ import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
13
14
  // Diagram types
14
15
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
15
16
  // Agent flow mode types
@@ -25,7 +26,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
25
26
  // Custom relation/code types
26
27
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
27
28
  // Mask types
28
- ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, } from './schemas.js';
29
+ ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
29
30
  import { OntologyCompiler } from '../ontology/ontology-compiler.js';
30
31
  // =============================================================================
31
32
  // HELPER FUNCTIONS
@@ -480,6 +481,19 @@ const DIRECTION_KEYS = ['direction', 'textDirection', 'dir'];
480
481
  // first STRONG character decides. Mirrored so a result can say which way
481
482
  // 'auto' went.
482
483
  const RTL_SCRIPT = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}]/u;
484
+ /**
485
+ * cursiveOptions as createCursiveText reads them. The engine takes `nib` as a
486
+ * boolean or a tuning object; the tool also takes the words 'monoline' and
487
+ * 'broad', which the engine would read as truthy (= broad) both times.
488
+ */
489
+ export function cursiveEngineOptions(o) {
490
+ if (!o)
491
+ return {};
492
+ const { nib, ...rest } = o;
493
+ if (nib === undefined)
494
+ return rest;
495
+ return { ...rest, nib: nib === 'monoline' ? false : nib === 'broad' ? true : nib };
496
+ }
483
497
  function baseDirection(text) {
484
498
  for (const ch of text) {
485
499
  if (RTL_SCRIPT.test(ch))
@@ -1477,7 +1491,9 @@ function generateGetItemsCode(filter) {
1477
1491
  if (filter?.source)
1478
1492
  conditions.push(`entry.source === '${filter.source}'`);
1479
1493
  if (filter?.hasAnimation !== undefined) {
1480
- conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
1494
+ // 'none' is what the engine stores on a still item, and !!'none' is true
1495
+ // (gate H5): animated means a real loop animation OR keyframes.
1496
+ conditions.push(`!!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)) === ${filter.hasAnimation}`);
1481
1497
  }
1482
1498
  if (filter?.hasRelation !== undefined) {
1483
1499
  conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
@@ -1490,7 +1506,7 @@ const items = entries.map(entry => ({
1490
1506
  id: entry.id || entry.itemId,
1491
1507
  type: entry.type,
1492
1508
  position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
1493
- hasAnimation: !!entry.item.data?.animationType,
1509
+ hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
1494
1510
  relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
1495
1511
  }));
1496
1512
 
@@ -1505,7 +1521,7 @@ const items = filtered.map(entry => ({
1505
1521
  id: entry.id || entry.itemId,
1506
1522
  type: entry.type,
1507
1523
  position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
1508
- hasAnimation: !!entry.item.data?.animationType,
1524
+ hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
1509
1525
  relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
1510
1526
  }));
1511
1527
 
@@ -1679,7 +1695,7 @@ app.setBackgroundColor('${color}');
1679
1695
  */
1680
1696
  function generateSetCanvasSizeCode(width, height, preset) {
1681
1697
  const sizeArg = preset
1682
- ? `'${preset}'`
1698
+ ? JSON.stringify(preset)
1683
1699
  : `{ width: ${width}, height: ${height} }`;
1684
1700
  return `
1685
1701
  // Set canvas size
@@ -1697,9 +1713,10 @@ function generateSetCanvasSizeCode(width, height, preset) {
1697
1713
  app.historyManager.saveState();
1698
1714
  // Report what the canvas ACTUALLY took where the engine says so, rather than
1699
1715
  // echoing the request back.
1700
- return { success: true, width: (r && r.width) || ${width}, height: (r && r.height) || ${height},
1716
+ const __sz = app.getCanvasSize ? app.getCanvasSize() : null;
1717
+ return { success: true, width: (r && r.width) || (__sz && __sz.width) || ${width ?? null}, height: (r && r.height) || (__sz && __sz.height) || ${height ?? null},
1701
1718
  ...(r && r.preset ? { preset: r.preset } : {}),
1702
- ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {})${!preset && (width > 8192 || height > 8192) ? `,
1719
+ ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {})${!preset && ((width ?? 0) > 8192 || (height ?? 0) > 8192) ? `,
1703
1720
  note: 'above 8192 px per side: Chrome and Firefox render it (the studio allows up to 16384 safely), but Safari and iOS may produce a blank or corrupt canvas — export from Chrome.'` : ''} };
1704
1721
  })();
1705
1722
  `.trim();
@@ -1939,6 +1956,108 @@ export function planKeyframeMerges(operations) {
1939
1956
  * Such a snippet's value is its last top-level (-led statement; the
1940
1957
  * font-loading form is an async IIFE, which is awaited instead.
1941
1958
  */
1959
+ /**
1960
+ * The active brand's defaults for a new item (D69): only what the caller did
1961
+ * not name. Closed shapes take colors.primary; text takes colors.text (or
1962
+ * primary) and the heading font from 32 px up, else the body font. Applied
1963
+ * through modifyItem after create, so the engine's own bookkeeping runs.
1964
+ */
1965
+ const BRAND_FILL_TYPES = new Set(['circle', 'star', 'rectangle', 'triangle', 'polygon', 'ellipse', 'pentagon', 'hexagon', 'diamond', 'arrow', 'heart']);
1966
+ function withBrandDefaults(code, itemType, props) {
1967
+ if (props.noBrand === true)
1968
+ return code;
1969
+ const isText = itemType === 'text';
1970
+ if (!isText && !BRAND_FILL_TYPES.has(itemType))
1971
+ return code;
1972
+ const hasColor = ['color', 'fillColor', 'fill'].some((k) => props[k] !== undefined);
1973
+ const hasFont = props.fontFamily !== undefined;
1974
+ if (hasColor && (!isText || hasFont))
1975
+ return code;
1976
+ const size = typeof props.fontSize === 'number' ? props.fontSize : 36;
1977
+ const anchor = 'const itemId = item.data.registryId;';
1978
+ if (!code.includes(anchor))
1979
+ return code;
1980
+ const patch = `
1981
+ // Active brand (D69): fill in what this call did not name.
1982
+ const __bk = (typeof window !== 'undefined' && window.__ppActiveBrand) || null;
1983
+ let __brand = null;
1984
+ if (__bk && __bk.colors && app.modifyItem) {
1985
+ const __bm = {};
1986
+ ${hasColor ? '' : `__bm.color = ${isText ? '__bk.colors.text || __bk.colors.primary' : '__bk.colors.primary'};`}
1987
+ ${isText && !hasFont ? `const __f = __bk.fonts && (${size >= 32 ? '__bk.fonts.heading || __bk.fonts.body' : '__bk.fonts.body || __bk.fonts.heading'}); if (__f) __bm.fontFamily = __f;` : ''}
1988
+ if (Object.keys(__bm).length) { app.modifyItem(itemId, __bm); __brand = __bm; }
1989
+ }`;
1990
+ return code.replace(anchor, anchor + patch).replace(/\(\{ itemId, type:/, '({ itemId, ...(__brand ? { brand: __brand } : {}), type:');
1991
+ }
1992
+ const ITEM_SETTERS = [
1993
+ { key: 'screenSpace', fn: 'setScreenSpace', what: 'screen-space (HUD) items', since: 'D20' },
1994
+ { key: 'stepTiming', fn: 'setStepTiming', what: 'step timing', since: 'D11' },
1995
+ { key: 'counter', fn: 'setCounter', what: 'counters', since: 'D70' },
1996
+ ];
1997
+ /** Lift screenSpace / hud / stepTiming out of a properties bag (mutated), explicit fields winning. */
1998
+ function takeItemSetters(props, explicit = {}) {
1999
+ const out = {};
2000
+ // From properties a caller may write hud: 1; it means on. The top-level
2001
+ // field is still a strict boolean.
2002
+ const ss = explicit.screenSpace ?? (props.screenSpace ?? props.hud);
2003
+ if (ss !== undefined)
2004
+ out.screenSpace = explicit.screenSpace !== undefined ? ScreenSpaceSchema.parse(ss) : !!ss;
2005
+ const st = explicit.stepTiming !== undefined ? explicit.stepTiming : props.stepTiming;
2006
+ if (st !== undefined)
2007
+ out.stepTiming = StepTimingSchema.parse(st);
2008
+ // A counter from properties is lifted only when it is a valid spec; anything
2009
+ // else stays where it was, for the engine to read and refuse by name.
2010
+ let liftCounter = explicit.counter !== undefined;
2011
+ if (liftCounter)
2012
+ out.counter = CounterSchema.parse(explicit.counter);
2013
+ else if (props.counter !== undefined && CounterSchema.safeParse(props.counter).success) {
2014
+ out.counter = props.counter;
2015
+ liftCounter = true;
2016
+ }
2017
+ delete props.screenSpace;
2018
+ delete props.hud;
2019
+ delete props.stepTiming;
2020
+ if (liftCounter)
2021
+ delete props.counter;
2022
+ return out;
2023
+ }
2024
+ function hasItemSetters(s) {
2025
+ return s.screenSpace !== undefined || s.stepTiming !== undefined || s.counter !== undefined;
2026
+ }
2027
+ /** Statements: refuse, before anything changes, when a requested setter is missing. */
2028
+ function itemSettersPreflightJs(s) {
2029
+ return ITEM_SETTERS.filter((d) => s[d.key] !== undefined).map((d) => ` if (typeof app.${d.fn} !== 'function') { return { success: false, error: 'this studio has no ${d.what} (app.${d.fn}) — it predates FxTool ${d.since}; nothing was changed.' }; }`).join('\n');
2030
+ }
2031
+ /** Statements: apply each setter to idExpr, folding the verdict into resVar. */
2032
+ function itemSettersApplyJs(s, idExpr, resVar) {
2033
+ return ITEM_SETTERS.filter((d) => s[d.key] !== undefined).map((d) => {
2034
+ const arg = JSON.stringify(s[d.key]);
2035
+ return ` {
2036
+ const __sr = app.${d.fn}(${idExpr}, ${arg});
2037
+ if (!__sr || __sr.ok === false) {
2038
+ return Object.assign({}, ${resVar}, { success: false, error: '${d.key} was not applied to ' + ${idExpr} + ': ' + ((__sr && (__sr.reason || __sr.error)) || 'refused by the studio') });
2039
+ }
2040
+ ${resVar}.${d.key} = ${d.key === 'screenSpace' ? arg : `(__sr.${d.key} !== undefined ? __sr.${d.key} : ${arg})`};${d.key === 'counter' ? `\n if (__sr.text !== undefined) ${resVar}.counterText = __sr.text;` : ''}
2041
+ }`;
2042
+ }).join('\n');
2043
+ }
2044
+ /** Wrap a create / modify snippet so the setters run after it, on its item. */
2045
+ function withItemSetters(code, s, idExpr) {
2046
+ if (!hasItemSetters(s))
2047
+ return code;
2048
+ return `(async function() {
2049
+ ${itemSettersPreflightJs(s)}
2050
+ const __res = await (async function() {
2051
+ ${asReturningBody(code)}
2052
+ })();
2053
+ if (!__res || __res.success === false) return __res;
2054
+ const __sid = ${idExpr};
2055
+ if (!__sid) return __res;
2056
+ ${itemSettersApplyJs(s, '__sid', '__res')}
2057
+ if (__res.success === undefined) __res.success = true;
2058
+ return __res;
2059
+ })();`;
2060
+ }
1942
2061
  function asReturningBody(snippet) {
1943
2062
  const single = snippet.trim();
1944
2063
  if (single.startsWith('(async function()'))
@@ -2210,8 +2329,11 @@ function world3dColor(value) {
2210
2329
  return value;
2211
2330
  const hex = value.trim().replace(/^#/, '');
2212
2331
  const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
2332
+ // A string that is not hex is refused HERE, naming both forms: passed on,
2333
+ // the engine answered "must be [r,g,b]", which contradicted the docs
2334
+ // saying hex works (gate A, 1.6.19).
2213
2335
  if (!/^[0-9a-f]{6}$/i.test(full))
2214
- return value;
2336
+ throw new Error(`"${value}" is not a colour: use hex ("#03050c" or "#abc") or [r, g, b]`);
2215
2337
  return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
2216
2338
  }
2217
2339
  /**
@@ -2313,6 +2435,52 @@ export const SHEET_RUBRIC = {
2313
2435
  'a jump between the last and first frame of a loop (loopSeam)',
2314
2436
  ],
2315
2437
  };
2438
+ /**
2439
+ * Page code: what a project holds, counted the same way before a save and
2440
+ * after a load, so a round trip can be checked (keyframes in = keyframes out).
2441
+ */
2442
+ const SCENE_COUNTS_JS = `function __ppSceneCounts() {
2443
+ const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
2444
+ let animated = 0, keyframes = 0;
2445
+ all.forEach(function (e) {
2446
+ const k = e && e.item && e.item.data && e.item.data.keyframes;
2447
+ if (Array.isArray(k) && k.length) { animated++; keyframes += k.length; }
2448
+ });
2449
+ const rel = (app.relationRegistry && typeof app.relationRegistry.exportForSave === 'function') ? app.relationRegistry.exportForSave().length : null;
2450
+ return { items: all.length, animatedItems: animated, keyframes: keyframes, relations: rel };
2451
+ }`;
2452
+ /**
2453
+ * Page code: the REGISTRY id for a Paper item, walking up from a child (a hit
2454
+ * test lands on a group's inner path). Results used to fall back to Paper's
2455
+ * internal numeric id (gate D9/D10, 1.6.19), which no other tool can address.
2456
+ */
2457
+ const REG_ID_JS = `function __ppRegId(it) {
2458
+ for (let n = it; n; n = n.parent) {
2459
+ const id = n.data && (n.data.id || n.data.registryId);
2460
+ if (id && app.itemRegistry && app.itemRegistry.get(id)) return id;
2461
+ }
2462
+ const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
2463
+ for (let n = it; n; n = n.parent) { const e = all.find(function (x) { return x && x.item === n; }); if (e) return e.itemId; }
2464
+ return null;
2465
+ }`;
2466
+ /**
2467
+ * Page code: any binary an engine export returns (Blob, ArrayBuffer, typed
2468
+ * array) as a data URL, so it crosses the bridge as bytes and the handler can
2469
+ * write a file. A Uint8Array crossed as a JSON object of byte indices
2470
+ * ({"0": 80, "1": 75, ...}): export_dotlottie was unusable (gate D7, 1.6.19).
2471
+ */
2472
+ const TO_DATA_URL_JS = `async function __ppToDataUrl(out, mime) {
2473
+ if (typeof out === 'string') return out.indexOf('data:') === 0 ? out : null;
2474
+ let blob = null;
2475
+ if (out instanceof Blob) blob = out;
2476
+ else if (out instanceof ArrayBuffer || ArrayBuffer.isView(out)) blob = new Blob([out], { type: mime });
2477
+ if (!blob) return null;
2478
+ return await new Promise(function (res, rej) { const r = new FileReader(); r.onload = function () { res(r.result); }; r.onerror = rej; r.readAsDataURL(blob); });
2479
+ }`;
2480
+ /** Why map pan / zoom refuse, and what to do instead. */
2481
+ const MAP_VIEW_REFUSAL = (action) => `map ${action} is not supported: the map has no view transform, and moving the camera changes only the studio view, `
2482
+ + 'which a canvas export ignores. To frame part of a map in an export: keyframe the camera with pinepaper_camera_animate '
2483
+ + 'and export with framing:"camera".';
2316
2484
  export class PinePaperCodeGenerator {
2317
2485
  /**
2318
2486
  * Generate code for creating an item
@@ -2333,17 +2501,27 @@ export class PinePaperCodeGenerator {
2333
2501
  properties.keyframes = validated.keyframes;
2334
2502
  if (validated.anchor !== undefined && properties.anchor === undefined && properties.origin === undefined)
2335
2503
  properties.anchor = validated.anchor;
2336
- return generateCreateItemCode(validated.itemType, validated.position, properties, validated.data, positionGiven);
2504
+ const setters = takeItemSetters(properties, { screenSpace: validated.screenSpace, stepTiming: validated.stepTiming, counter: validated.counter });
2505
+ const brandProps = { ...properties };
2506
+ delete properties.noBrand;
2507
+ return withItemSetters(withBrandDefaults(generateCreateItemCode(validated.itemType, validated.position, properties, validated.data, positionGiven), validated.itemType, brandProps), setters, '__res.itemId');
2337
2508
  }
2338
2509
  /**
2339
2510
  * Generate code for modifying an item
2340
2511
  */
2341
2512
  generateModifyItem(input) {
2342
2513
  const validated = ModifyItemInputSchema.parse(input);
2514
+ const props = { ...validated.properties };
2515
+ const setters = takeItemSetters(props, { screenSpace: validated.screenSpace, stepTiming: validated.stepTiming, counter: validated.counter });
2516
+ const idExpr = JSON.stringify(validated.itemId);
2343
2517
  if (validated.atTime !== undefined) {
2344
- return generateAutoKeyCode(validated.itemId, validated.properties, validated.atTime, validated.easing);
2518
+ return withItemSetters(generateAutoKeyCode(validated.itemId, props, validated.atTime, validated.easing), setters, idExpr);
2345
2519
  }
2346
- return generateModifyItemCode(validated.itemId, validated.properties, validated.data);
2520
+ // Only a setter asked for: no property edit to run.
2521
+ if (hasItemSetters(setters) && Object.keys(props).length === 0 && !validated.data) {
2522
+ return withItemSetters(`({ success: true, itemId: ${idExpr} });`, setters, idExpr);
2523
+ }
2524
+ return withItemSetters(generateModifyItemCode(validated.itemId, props, validated.data), setters, idExpr);
2347
2525
  }
2348
2526
  /**
2349
2527
  * Generate code for deleting an item
@@ -2354,8 +2532,14 @@ export class PinePaperCodeGenerator {
2354
2532
  /**
2355
2533
  * Generate code for adding a relation
2356
2534
  */
2357
- generateAddRelation(input) {
2358
- const validated = AddRelationInputSchema.parse(input);
2535
+ generateAddRelation(input, customName = false) {
2536
+ // The handler has already parsed, and a session-registered custom
2537
+ // relation name is not in the enum (gate D2); the handler says so, and the
2538
+ // name is re-parsed with a built-in stand-in and put back.
2539
+ const rt = input.relationType;
2540
+ const validated = AddRelationInputSchema.parse(customName ? { ...input, relationType: 'orbits' } : input);
2541
+ if (customName)
2542
+ validated.relationType = rt;
2359
2543
  // A PRESET decides its own relationType and params, so this is a different
2360
2544
  // call, not a defaulted one. applyRelationPreset refuses with
2361
2545
  // `{ok:false, errors:[{field, reason}]}` — a third refusal shape on this
@@ -2381,7 +2565,15 @@ export class PinePaperCodeGenerator {
2381
2565
  if (validated.relationType === 'moves_along_path') {
2382
2566
  return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
2383
2567
  }
2384
- return generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
2568
+ const code = generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
2569
+ // NO TARGET (event reactions like on_event_play_sound, self-relations): the
2570
+ // engine is handed the source as the target, but the reply must not say
2571
+ // "item_2 → item_2" — a model reads that as the event wired to itself.
2572
+ if (validated.targetId)
2573
+ return code;
2574
+ return code
2575
+ .replace(`return { success: true, sourceId: '${validated.sourceId}', targetId: '${validated.sourceId}',`, `return { success: true, sourceId: '${validated.sourceId}', targetId: null,`)
2576
+ .replace(`relation between ${validated.sourceId} and ${validated.sourceId}.`, `relation on ${validated.sourceId} (no target).`);
2385
2577
  }
2386
2578
  /**
2387
2579
  * Generate code for removing a relation
@@ -2407,7 +2599,19 @@ export class PinePaperCodeGenerator {
2407
2599
  */
2408
2600
  generateKeyframeAnimate(input) {
2409
2601
  const validated = KeyframeAnimateInputSchema.parse(input);
2410
- return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
2602
+ // Hold keys (FxTool D11) on an engine without them would interpolate in
2603
+ // silence; refuse before anything is written. setStepTiming ships in the
2604
+ // same change, so it is the probe.
2605
+ const holds = validated.keyframes.some((k) => k.interpolation === 'hold' || k.hold === true);
2606
+ const code = generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
2607
+ if (!holds)
2608
+ return code;
2609
+ return `(async function() {
2610
+ if (!(typeof app.setStepTiming === 'function')) { return { success: false, error: 'this studio has no hold keys (interpolation: \\'hold\\') — it predates FxTool D11; nothing was written. Use two keys a frame apart for a cut.' }; }
2611
+ return await (async function() {
2612
+ ${asReturningBody(code)}
2613
+ })();
2614
+ })();`;
2411
2615
  }
2412
2616
  /**
2413
2617
  * Generate code for importing / retargeting a motion-capture clip
@@ -2655,6 +2859,15 @@ throw new Error('Either svgString or url must be provided');
2655
2859
  success: !!result.success,
2656
2860
  nodeCount: nodes.length,
2657
2861
  edgeCount: edges.length,
2862
+ // CANVAS ids, the ones auto_layout / animate / add_relation take. The
2863
+ // engine's own nodeIds are the MERMAID ids (A, B…), which resolve to
2864
+ // nothing on the canvas (gate D45). Newer engines return nodeItemIds;
2865
+ // otherwise they are read off the registered nodes.
2866
+ nodeItemIds: Array.isArray(result.nodeItemIds) && result.nodeItemIds.length
2867
+ ? result.nodeItemIds
2868
+ : nodes.map((n) => n.id).filter((id) => typeof id === 'string' && /^item_/.test(id)),
2869
+ ...(result.nodeIdMap && typeof result.nodeIdMap === 'object' ? { nodeIdMap: result.nodeIdMap } : {}),
2870
+ ...(Array.isArray(result.edgeIds) && result.edgeIds.length ? { edgeIds: result.edgeIds } : {}),
2658
2871
  nodes,
2659
2872
  edges,
2660
2873
  errors: result.errors || [],
@@ -3068,9 +3281,12 @@ if (typeof _gen === 'function') {
3068
3281
  codeParts.push(`
3069
3282
  // Save history and return results
3070
3283
  app.historyManager.saveState();
3071
- results;
3284
+ return results;
3072
3285
  `);
3073
- return codeParts.join('\n');
3286
+ // ONE (-led FUNCTION (gate C4, 1.6.19). This was a statement list ending
3287
+ // in a bare \`results;\`, and the governor keeps only a (-led snippet's
3288
+ // value, so every created id was collected and then dropped.
3289
+ return `(async function() {\n${codeParts.join('\n')}\n})();`;
3074
3290
  }
3075
3291
  // =============================================================================
3076
3292
  // DIAGRAM TOOLS
@@ -3191,12 +3407,26 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
3191
3407
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
3192
3408
 
3193
3409
  const config = ${configStr};
3194
- const connector = app.diagramSystem.connectPorts(
3195
- sourceItem, '${sourcePort}',
3196
- targetItem, '${targetPort}',
3197
- config
3198
- );
3199
- const connectorId = connector.data?.registryId || connector.id;
3410
+ // connectPorts(sourcePort, targetPort, config) takes PORT OBJECTS (gate C8,
3411
+ // 1.6.19). This passed (item, name, item, name, config): five arguments to a
3412
+ // three-argument method, so the engine got an item where a port belongs,
3413
+ // returned null, and connector.data threw. Ports are resolved by name or
3414
+ // position first; an item with none gets the standard diagram ports.
3415
+ const __pm = app.diagramSystem.portManager;
3416
+ const __port = function (item, name) {
3417
+ let p = __pm.getPort(item, name);
3418
+ if (!p && !(__pm.getPorts(item) || []).length) { app.diagramSystem.addPorts(item); p = __pm.getPort(item, name); }
3419
+ return p;
3420
+ };
3421
+ const __sp = __port(sourceItem, ${JSON.stringify(sourcePort)});
3422
+ const __tp = __port(targetItem, ${JSON.stringify(targetPort)});
3423
+ if (!__sp || !__tp) {
3424
+ const __names = function (item) { return (__pm.getPorts(item) || []).map(function (p) { return p.position || p.id; }).join(', '); };
3425
+ return { success: false, error: 'no such port: ' + (!__sp ? ${JSON.stringify(sourcePort)} + ' on ' + ${JSON.stringify(sourceItemId)} + ' (has: ' + __names(sourceItem) + ')' : ${JSON.stringify(targetPort)} + ' on ' + ${JSON.stringify(targetItemId)} + ' (has: ' + __names(targetItem) + ')') };
3426
+ }
3427
+ const connector = app.diagramSystem.connectPorts(__sp, __tp, config);
3428
+ if (!connector) { return { success: false, error: 'the diagram system did not create the connector' }; }
3429
+ const connectorId = connector.id || connector.data?.registryId;
3200
3430
  app.historyManager.saveState();
3201
3431
 
3202
3432
  ({ connectorId, sourceItemId: '${sourceItemId}', sourcePort: '${sourcePort}', targetItemId: '${targetItemId}', targetPort: '${targetPort}' });
@@ -3291,61 +3521,50 @@ const shapes = category ? _lib.getByCategory(category) : _lib.getAll();
3291
3521
  generateUpdateConnector(input) {
3292
3522
  const validated = UpdateConnectorInputSchema.parse(input);
3293
3523
  const { connectorId, style, label, labelPosition } = validated;
3294
- const updates = [];
3295
- if (style) {
3296
- updates.push(`style: ${JSON.stringify(style)}`);
3297
- }
3298
- if (label !== undefined) {
3299
- updates.push(`label: ${JSON.stringify(label)}`);
3300
- }
3301
- if (labelPosition !== undefined) {
3302
- updates.push(`labelPosition: ${labelPosition}`);
3303
- }
3304
- const updatesStr = updates.length > 0 ? `{ ${updates.join(', ')} }` : '{}';
3305
3524
  return `
3306
3525
  // Update connector: ${connectorId}
3307
- const connector = app.getItemById('${connectorId}');
3308
- if (!connector) throw new Error('Connector not found: ${connectorId}');
3309
-
3310
- const updates = ${updatesStr};
3311
-
3312
- // THERE IS NO updateConnector. ConnectorManager publishes removeConnector,
3313
- // findConnector and updateSelectedStyle — nothing that edits one connector's
3314
- // properties in place. The old emitter called app.diagramSystem.updateConnector
3315
- // and reported updated:true, so a caller changing a connector's colour got a
3316
- // success and an unchanged connector.
3317
- //
3318
- // Applied directly where the property is a plain Paper.js one, and refused by
3319
- // name where it is not, rather than claiming a change that did not happen.
3320
- // The engine gained getConnector(id) / updateConnector(id, style), which is
3321
- // the call this tool always wanted: addressable by the id a connector is
3322
- // created and serialised with. Guarded, because a studio predating that still
3323
- // has to do something sensible rather than throw.
3324
- if (typeof app.diagramSystem?.updateConnector === 'function') {
3325
- const res = app.diagramSystem.updateConnector('${connectorId}', updates);
3326
- if (res && res.ok === false) { return { success: false, error: res.reason || 'the connector could not be updated' }; }
3526
+ (function() {
3527
+ // CONNECTORS LIVE IN THE DIAGRAM SYSTEM, NOT THE ITEM REGISTRY (gate D1,
3528
+ // 1.6.19): getItemById never resolves the id connect returned.
3529
+ const __cid = ${JSON.stringify(connectorId)};
3530
+ const ds = app.diagramSystem;
3531
+ const connector = ds && typeof ds.getConnector === 'function' ? ds.getConnector(__cid) : null;
3532
+ if (!connector) return { success: false, error: 'Connector not found: ' + __cid + '. Use the connectorId pinepaper_connect returned.' };
3533
+ // updateConnector(id, style) takes the STYLE BAG ITSELF. This passed
3534
+ // { style: {...}, label } (gate run 2, 1.6.19), so the engine merged a key
3535
+ // named "style" into the connector's style, changed nothing it draws, and
3536
+ // the tool reported updated:true over an unchanged connector.
3537
+ const style = ${JSON.stringify(style ?? {})};
3538
+ const label = ${label === undefined ? 'undefined' : JSON.stringify(label)};
3539
+ const labelPosition = ${labelPosition === undefined ? 'undefined' : labelPosition};
3540
+ const keys = Object.keys(style);
3541
+ if (!keys.length && label === undefined && labelPosition === undefined) {
3542
+ return { success: false, error: 'nothing to update: pass style, label or labelPosition' };
3543
+ }
3544
+ const applied = [];
3545
+ if (keys.length) {
3546
+ if (typeof ds.updateConnector !== 'function') {
3547
+ return { success: false, error: 'this studio has no per-connector update (app.diagramSystem.updateConnector); update the studio' };
3548
+ }
3549
+ const res = ds.updateConnector(__cid, style);
3550
+ if (!res || res.ok === false) return { success: false, error: (res && res.reason) || 'the connector could not be updated' };
3551
+ const unchanged = keys.filter(function (k) { return connector.style[k] !== style[k]; });
3552
+ if (unchanged.length) return { success: false, error: 'the studio did not take ' + unchanged.join(', ') };
3553
+ keys.forEach(function (k) { applied.push('style.' + k); });
3554
+ }
3555
+ if (label !== undefined || labelPosition !== undefined) {
3556
+ connector.setLabel(label !== undefined ? label : connector.label, labelPosition !== undefined ? labelPosition : connector.labelPosition);
3557
+ applied.push(label !== undefined ? 'label' : 'labelPosition');
3558
+ }
3327
3559
  app.historyManager.saveState();
3328
- return { connectorId: '${connectorId}', updated: true, applied: Object.keys(updates) };
3329
- }
3330
-
3331
- const _applied = [];
3332
- const _unsupported = [];
3333
- for (const [k, v] of Object.entries(updates)) {
3334
- if (k === 'lineColor' && connector.strokeColor !== undefined) { connector.strokeColor = v; _applied.push(k); }
3335
- else if (k === 'lineWidth' && connector.strokeWidth !== undefined) { connector.strokeWidth = v; _applied.push(k); }
3336
- else _unsupported.push(k);
3337
- }
3338
- if (_applied.length) { app.historyManager.saveState(); app._scheduleRepaint?.(); }
3339
- if (_unsupported.length && !_applied.length) {
3340
- throw new Error(
3341
- 'this build cannot update ' + _unsupported.join(', ') + ' on an existing connector — '
3342
- + 'this build has no per-connector update — app.diagramSystem.updateConnector arrived later. '
3343
- + 'Update the studio for it. Removing and recreating the connector works, but mints a NEW id '
3344
- + 'and orphans anything holding the old one, so it is a last resort rather than the fix.'
3345
- );
3346
- }
3347
-
3348
- ({ connectorId: '${connectorId}', updated: _applied.length > 0, applied: _applied, unsupported: _unsupported });
3560
+ // What it now DRAWS, read off the path, so a caller can check rather than trust.
3561
+ const p = connector.path;
3562
+ return {
3563
+ success: true, connectorId: __cid, updated: true, applied: applied,
3564
+ drawn: p ? { stroke: p.strokeColor ? p.strokeColor.toCSS(true) : null, width: p.strokeWidth, dash: p.dashArray || [] } : null,
3565
+ label: connector.label || null,
3566
+ };
3567
+ })();
3349
3568
  `.trim();
3350
3569
  }
3351
3570
  /**
@@ -3356,8 +3575,10 @@ if (_unsupported.length && !_applied.length) {
3356
3575
  const { connectorId } = validated;
3357
3576
  return `
3358
3577
  // Remove connector: ${connectorId}
3359
- const connector = app.getItemById('${connectorId}');
3360
- if (!connector) throw new Error('Connector not found: ${connectorId}');
3578
+ // Resolved through the diagram system (gate D1, 1.6.19), as update_connector is.
3579
+ const __cid = ${JSON.stringify(connectorId)};
3580
+ const connector = (app.diagramSystem && app.diagramSystem.getConnector(__cid)) || app.getItemById(__cid);
3581
+ if (!connector) throw new Error('Connector not found: ' + __cid);
3361
3582
 
3362
3583
  app.diagramSystem.connectorManager.removeConnector(connector);
3363
3584
  app.historyManager.saveState();
@@ -3446,6 +3667,23 @@ throw new Error('Unknown diagram mode action: ${action}');
3446
3667
  app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
3447
3668
  // Allow canvas resize to take effect
3448
3669
  await new Promise(r => setTimeout(r, 50));
3670
+ `;
3671
+ }
3672
+ else {
3673
+ code += `
3674
+ // BOUND THE BOARD IT REPORTS (gate D57). An UNBOUNDED canvas still answers
3675
+ // getCanvasSize with its 1920x1080 fallback, which is what this job reports
3676
+ // and what every sizing rule here is relative to; but its stills export the
3677
+ // artwork's bounds plus a margin (432x232 for one rectangle), while video
3678
+ // exports the board. Fixing it at the size reported makes the two agree.
3679
+ let _boundedFrom = null;
3680
+ if (app.canvasSize && app.canvasSize.unbounded && typeof app.setCanvasSize === 'function') {
3681
+ const _u = app.getCanvasSize ? app.getCanvasSize() : app.canvasSize;
3682
+ if (_u && _u.width > 0 && _u.height > 0) {
3683
+ const _r = app.setCanvasSize({ width: _u.width, height: _u.height });
3684
+ if (!(_r && _r.ok === false)) _boundedFrom = 'unbounded';
3685
+ }
3686
+ }
3449
3687
  `;
3450
3688
  }
3451
3689
  if (wantOntology) {
@@ -3479,7 +3717,8 @@ throw new Error('Unknown diagram mode action: ${action}');
3479
3717
  screenshotPolicy: '${policyStr}',
3480
3718
  canvasPreset: ${canvasPreset ? `'${canvasPreset}'` : 'null'},
3481
3719
  canvasCleared: ${shouldClear},
3482
- canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }${wantOntology ? `,
3720
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }${canvasPreset ? '' : `,
3721
+ ...(_boundedFrom ? { note: 'the canvas was unbounded (it exported the artwork, not the board); it is now fixed at ' + (_cs.width || 800) + 'x' + (_cs.height || 600) + ', so stills and video both export that size.' } : {})`}${wantOntology ? `,
3483
3722
  ontology: _ontology,
3484
3723
  ontologyUnavailable: _ontologyUnavailable || undefined` : ''}
3485
3724
  };
@@ -3499,6 +3738,8 @@ throw new Error('Unknown diagram mode action: ${action}');
3499
3738
  let code = `
3500
3739
  // End agent job
3501
3740
  (async function() {
3741
+ // Close the studio's job (D92). It never reloads.
3742
+ try { if (typeof window !== 'undefined' && window.PinePaperAgent && window.PinePaperAgent.endJob) window.PinePaperAgent.endJob(); } catch (_) { /* older studio */ }
3502
3743
  const result = {
3503
3744
  success: true,
3504
3745
  jobEnded: true
@@ -3558,12 +3799,13 @@ throw new Error('Unknown diagram mode action: ${action}');
3558
3799
 
3559
3800
  if (entry.type === 'text' || item.className === 'PointText') analysis.hasText = true;
3560
3801
  if (item.className === 'Raster') analysis.hasImages = true;
3561
- if (data.animationType) {
3802
+ // 'none' is what a still item stores (gate C5, 1.6.19): it is not an animation.
3803
+ if (data.animationType && data.animationType !== 'none') {
3562
3804
  analysis.hasAnimations = true;
3563
3805
  animationSet.add(data.animationType);
3564
3806
  }
3565
3807
  // Detect keyframe animations (added via app.addAnimation with keyframes array)
3566
- if (data.keyframes || data.animation || data.keyframeAnimation) {
3808
+ if ((Array.isArray(data.keyframes) && data.keyframes.length > 0) || data.animation || data.keyframeAnimation) {
3567
3809
  analysis.hasAnimations = true;
3568
3810
  animationSet.add('keyframe');
3569
3811
  }
@@ -3802,12 +4044,19 @@ throw new Error('Unknown diagram mode action: ${action}');
3802
4044
  // the exact emitter create_item uses, adapted to return its result
3803
4045
  // inside this op's function.
3804
4046
  const pos = op.position || { x: 400, y: 300 };
3805
- const createProps = withRadiusAxes((op.properties || {}));
3806
- const returning = asReturningBody(generateCreateItemCode(op.itemType, pos, { ...(op.properties || {}) }, undefined, op.position !== undefined));
3807
- let createCode = `
4047
+ const opProps = { ...(op.properties || {}) };
4048
+ const opSetters = takeItemSetters(opProps, { screenSpace: op.screenSpace, stepTiming: op.stepTiming, counter: op.counter });
4049
+ const createProps = withRadiusAxes(opProps);
4050
+ const opBrand = { ...opProps };
4051
+ delete opProps.noBrand;
4052
+ const returning = asReturningBody(withBrandDefaults(generateCreateItemCode(op.itemType, pos, { ...opProps }, undefined, op.position !== undefined), op.itemType, opBrand));
4053
+ let createCode = `${hasItemSetters(opSetters) ? `\n${itemSettersPreflightJs(opSetters)}` : ''}
3808
4054
  const __created = await (async function() {
3809
4055
  ${returning}
3810
- })();`;
4056
+ })();${hasItemSetters(opSetters) ? `
4057
+ if (__created && __created.success !== false && __created.itemId) {
4058
+ ${itemSettersApplyJs(opSetters, '__created.itemId', '__created')}
4059
+ }` : ''}`;
3811
4060
  // Coordinate-built items (path from segments/pathData, line/arc from
3812
4061
  // from/through/to) derive their geometry from those coordinates and
3813
4062
  // IGNORE params.position — so `create` at a point silently produced an
@@ -3915,7 +4164,7 @@ return { success: true, backgroundColor: '${bgColor}' };
3915
4164
  case 'set_canvas_size': {
3916
4165
  const w = op.width || 1080;
3917
4166
  const h = op.height || 1080;
3918
- const sizeArg = op.preset ? `'${op.preset}'` : `{ width: ${w}, height: ${h} }`;
4167
+ const sizeArg = op.preset ? JSON.stringify(op.preset) : `{ width: ${w}, height: ${h} }`;
3919
4168
  return `
3920
4169
  // Read the verdict, as the standalone tool now does: an unknown preset or an
3921
4170
  // out-of-range dimension is refused by name, and reporting the size we ASKED
@@ -4403,7 +4652,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4403
4652
  // A 3D world under the canvas IS the background: since FxTool #50
4404
4653
  // (747315e3) jpg/webp stills composite it, so nothing turns white/black.
4405
4654
  const hasWorld = !!app._world3d;
4406
- if (hasColor || hasBgItems || hasWorld) return [];
4655
+ // So does a styled scene: one raster sized to COVER the frame (gate run 3:
4656
+ // every export after styled_scene warned of black areas it does not have).
4657
+ const hasStyledScene = !!(app.textItemGroup && app.textItemGroup.children
4658
+ && app.textItemGroup.children.some(function (it) { return it && it.data && it.data.styledScene && it.visible !== false; }));
4659
+ if (hasColor || hasBgItems || hasWorld || hasStyledScene) return [];
4407
4660
  return [{ code: 'alpha_dropped', message: fmt === 'apng'
4408
4661
  ? 'apng with transparent: false and no background colour: the frames are filled BLACK. Set a background colour, or drop transparent: false to keep the alpha.'
4409
4662
  : fmt === 'jpg'
@@ -4530,7 +4783,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4530
4783
  camBase = { width: app.canvasSize.width, height: app.canvasSize.height };
4531
4784
  }
4532
4785
  if (!camBase) camBase = { width: dimensions.width, height: dimensions.height };
4533
- cameraDims = { width: camBase.width, height: camBase.height };
4786
+ // scale applies here too: it was dropped, so scale 0.5 rendered full
4787
+ // size and nothing said so (gate C7).
4788
+ const __cs = ${scale !== undefined ? scale : 1};
4789
+ const __ev = (n) => Math.max(2, Math.round((n * __cs) / 2) * 2);
4790
+ cameraDims = { width: __ev(camBase.width), height: __ev(camBase.height) };
4534
4791
  }
4535
4792
 
4536
4793
  // AN EXPORTER ANSWERS A BLOB OR A RECORD AROUND ONE.
@@ -4693,6 +4950,12 @@ ${stillTime !== undefined ? `
4693
4950
  ? { width: png.width, height: png.height }
4694
4951
  : { width: dimensions.width, height: dimensions.height },
4695
4952
  };
4953
+ // An unbounded canvas exports its ARTWORK (bounds plus a margin), not
4954
+ // the board getCanvasSize reports (gate D57). Say so rather than
4955
+ // hand back a size nobody asked for unexplained.
4956
+ if (app.canvasSize && app.canvasSize.unbounded) {
4957
+ result.warning = (result.warning ? result.warning + ' ' : '') + 'this canvas is unbounded, so the still is cropped to the artwork, not the ' + pngBoard.width + 'x' + pngBoard.height + ' board. pinepaper_set_canvas_size {width, height} fixes the board; video exports the board either way.';
4958
+ }
4696
4959
  } else {
4697
4960
  const canvas = document.querySelector('canvas');
4698
4961
  if (canvas) {
@@ -5369,6 +5632,49 @@ ${(() => {
5369
5632
  })()}
5370
5633
  ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
5371
5634
  if (result && result.success) result.time = ${stillTime};` : ''}
5635
+ // SCREEN-SPACE ITEMS (FxTool D20): SVG and Lottie write geometry, not the
5636
+ // camera's frame map, so a HUD item is placed as if the camera had not moved.
5637
+ if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(${JSON.stringify(format)}) !== -1) {
5638
+ try {
5639
+ const __hud = app.screenSpaceItems() || [];
5640
+ if (__hud.length) {
5641
+ result.fidelity = result.fidelity || { warnings: [] };
5642
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'screen_space_ignored', message: __hud.length + ' screen-space (HUD) item(s) are written at their frame coordinates in world space: ${format} has no camera frame, so they do not stay fixed under the camera move. Export mp4, webm, gif or png to keep them fixed.' }]);
5643
+ }
5644
+ } catch (_) { /* the export stands */ }
5645
+ }
5646
+ // WHICH STUDIO MADE THIS FILE (D92): a service worker can serve a stale
5647
+ // build right after a deploy, and a retest then judges old code. The
5648
+ // engine's own identity, version + build sha, rides on every export reply.
5649
+ try { if (result && typeof result === 'object' && app.buildId) result.studioBuild = app.buildId; } catch (_) { /* older studio */ }
5650
+ // A STALE STUDIO (D92): the file was made by an old build. Said in the
5651
+ // fidelity warnings the reply shows, not only as a field.
5652
+ try {
5653
+ const __bs = app.buildStatus ? app.buildStatus() : null;
5654
+ if (result && typeof result === 'object' && __bs) {
5655
+ result.liveBuild = __bs.liveBuildId;
5656
+ if (__bs.stale) {
5657
+ result.stale = true;
5658
+ result.fidelity = result.fidelity || { warnings: [] };
5659
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'stale_build', message: 'this studio is running an old build (' + __bs.buildId + '; the live build is ' + __bs.liveBuildId + '): this file was made by the old code. Start a new job (pinepaper_agent_start_job, which reloads it) and export again.' }]);
5660
+ }
5661
+ }
5662
+ } catch (_) { /* older studio */ }
5663
+ // RELIGHT (FxTool D5): applied to png / pdf / mp4 / webm, not gif / apng
5664
+ // or the vector formats; and it is what makes a video export slow.
5665
+ if (result && result.success && typeof app.getRelight === 'function') {
5666
+ try {
5667
+ const __rl = app.getRelight();
5668
+ if (__rl && __rl.enabled) {
5669
+ result.fidelity = result.fidelity || { warnings: [] };
5670
+ if (['png', 'jpg', 'webp', 'pdf', 'mp4', 'webm'].indexOf(${JSON.stringify(format)}) === -1) {
5671
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'relight_not_applied', message: 'a relight rig is set, but ${format} is not relit. Export png, pdf, mp4 or webm to keep the lighting.' }]);
5672
+ } else {
5673
+ result.relight = { applied: true, lights: (__rl.lights || []).length };
5674
+ }
5675
+ }
5676
+ } catch (_) { /* the export stands */ }
5677
+ }
5372
5678
 
5373
5679
  return result;
5374
5680
  })();
@@ -5436,12 +5742,13 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5436
5742
  if (entry.type === 'text' || item.className === 'PointText') analysis.hasText = true;
5437
5743
  if (item.className === 'Raster') analysis.hasImages = true;
5438
5744
 
5439
- if (data.animationType) {
5745
+ // 'none' is what a still item stores (gate C5, 1.6.19): it is not an animation.
5746
+ if (data.animationType && data.animationType !== 'none') {
5440
5747
  analysis.hasAnimations = true;
5441
5748
  animationSet.add(data.animationType);
5442
5749
  }
5443
5750
  // Detect keyframe animations (added via app.addAnimation with keyframes array)
5444
- if (data.keyframes || data.animation || data.keyframeAnimation) {
5751
+ if ((Array.isArray(data.keyframes) && data.keyframes.length > 0) || data.animation || data.keyframeAnimation) {
5445
5752
  analysis.hasAnimations = true;
5446
5753
  animationSet.add('keyframe');
5447
5754
  }
@@ -5693,7 +6000,9 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5693
6000
  return {
5694
6001
  success: true,
5695
6002
  mapId: result.mapId || '${mapId}',
5696
- regions: result.regions?.length || 0,
6003
+ // regionPaths is a Map: .length was undefined, so every load reported
6004
+ // regions: 0 (gate D3). size, with the feature count as a fallback.
6005
+ regions: (result.regions && (result.regions.size ?? result.regions.length)) || (result.geoData && result.geoData.features ? result.geoData.features.length : 0),
5697
6006
  bounds: result.bounds,
5698
6007
  center: result.center,${warn}
5699
6008
  };
@@ -5702,6 +6011,55 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5702
6011
  }
5703
6012
  })();
5704
6013
  `.trim();
6014
+ }
6015
+ /**
6016
+ * Emitted helpers for reading a map's region colours back.
6017
+ *
6018
+ * highlightRegions and applyDataColors both write ONE channel — a colour
6019
+ * override keyed on the feature's _mapId — and log a console.warn for an id
6020
+ * they cannot resolve, which production strips. getHighlightedRegions reads
6021
+ * path.data.highlighted over regionPaths, which a rasterised map leaves
6022
+ * empty, so it saw nothing either tool did (gate D51). The override, read
6023
+ * through the public getRegionOverride, is the record the engine leaves.
6024
+ * Ids resolve the way the engine resolves them: exact, then lower-case.
6025
+ */
6026
+ mapRegionReadbackJs() {
6027
+ return `
6028
+ function __resolve(id) {
6029
+ const m = app.mapSystem.regionNameMappings;
6030
+ if (!m || typeof m.get !== 'function') return null;
6031
+ return m.get(id) || m.get(String(id).toLowerCase()) || null;
6032
+ }
6033
+ function __features() {
6034
+ const seen = new Map();
6035
+ app.mapSystem.regionNameMappings.forEach(function (f) { if (f && !seen.has(f._mapId)) seen.set(f._mapId, f); });
6036
+ return Array.from(seen.values());
6037
+ }
6038
+ function __row(f, asked) {
6039
+ const o = app.mapSystem.getRegionOverride(f._mapId);
6040
+ const r = { regionId: f._mapId, name: f._mapName, fill: o ? o.fill : null };
6041
+ if (asked !== undefined) r.asked = asked;
6042
+ return r;
6043
+ }
6044
+ function __overridden() {
6045
+ return __features().filter(function (f) { return !!app.mapSystem.getRegionOverride(f._mapId); })
6046
+ .map(function (f) { return __row(f); });
6047
+ }
6048
+ // No map loaded: every id is "not on this map", which blames the ids.
6049
+ const __noMap = { success: false, error: 'no map is loaded. Load one with pinepaper_map {action: "load"} first.' };
6050
+ function __sampleIds() {
6051
+ return __features().slice(0, 8).map(function (f) { return f._mapId + ' (' + f._mapName + ')'; }).join(', ');
6052
+ }
6053
+ function __check(asked) {
6054
+ const done = [], notFound = [], notApplied = [];
6055
+ asked.forEach(function (id) {
6056
+ const f = __resolve(id);
6057
+ if (!f) { notFound.push(id); return; }
6058
+ const r = __row(f, id);
6059
+ (r.fill ? done : notApplied).push(r);
6060
+ });
6061
+ return { done: done, notFound: notFound, notApplied: notApplied };
6062
+ }`;
5705
6063
  }
5706
6064
  /**
5707
6065
  * Generate code to highlight map regions
@@ -5720,13 +6078,19 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5720
6078
  const fill = ho.color ?? ho.fillColor ?? ho.fill;
5721
6079
  if (fill !== undefined)
5722
6080
  style.fill = fill;
6081
+ // OPACITY IS A TINT OVER THE LAND (gate D59). The override REPLACES a
6082
+ // region's land fill rather than drawing over it, and an rgba fill lost
6083
+ // its alpha on the way to the exported pixels, so the readback reported a
6084
+ // blend the PNG did not draw. The colour is blended over each region's own
6085
+ // land fill in the browser and sent opaque: export and readback agree.
6086
+ const opacity = typeof ho.opacity === 'number' ? ho.opacity : null;
5723
6087
  const stroke = ho.strokeColor ?? ho.stroke;
5724
6088
  if (stroke !== undefined)
5725
6089
  style.stroke = stroke;
5726
6090
  if (ho.strokeWidth !== undefined)
5727
6091
  style.strokeWidth = ho.strokeWidth;
5728
6092
  for (const [k, v] of Object.entries(ho)) {
5729
- if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth'].includes(k) && v !== undefined) {
6093
+ if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth', 'opacity'].includes(k) && v !== undefined) {
5730
6094
  style[k] = v;
5731
6095
  }
5732
6096
  }
@@ -5738,9 +6102,68 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5738
6102
  return { success: false, error: 'Map system not available' };
5739
6103
  }
5740
6104
 
5741
- try {
5742
- app.mapSystem.highlightRegions(${regionIds}, ${optionsStr});
5743
- return { success: true, highlighted: ${regionIds} };
6105
+ try {${this.mapRegionReadbackJs()}
6106
+ if (!__features().length) return __noMap;
6107
+ const __style = ${optionsStr};
6108
+ const __op = ${JSON.stringify(opacity)};
6109
+ const __expected = {};
6110
+ const __unblended = [];
6111
+ if (__op !== null && typeof __style.fill === 'string') {
6112
+ const __rgb = function (c) {
6113
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || ''));
6114
+ if (!m) return null;
6115
+ const x = m[1].length === 3 ? m[1].replace(/./g, function (ch) { return ch + ch; }) : m[1];
6116
+ return [0, 2, 4].map(function (i) { return parseInt(x.slice(i, i + 2), 16); });
6117
+ };
6118
+ const __src = __rgb(__style.fill);
6119
+ const __p = app.mapSystem._mapRenderParams;
6120
+ const __ms = (__p && __p.mergedStyles) || {};
6121
+ const __user = (app.mapSystem.currentMap && app.mapSystem.currentMap.geoData && app.mapSystem.currentMap.geoData.features) || [];
6122
+ ${regionIds}.forEach(function (id) {
6123
+ const f = __resolve(id);
6124
+ if (!f) return;
6125
+ // The land the engine paints under this region: a user feature takes
6126
+ // the map's fill, a basemap country its basemapFill.
6127
+ const __land = __rgb(__user.indexOf(f) !== -1 ? __ms.fill
6128
+ : (__ms.basemapFill || (app.mapSystem.defaultStyles || {}).basemapFill));
6129
+ let __fill = __style.fill;
6130
+ if (__src && __land) {
6131
+ __fill = '#' + [0, 1, 2].map(function (i) {
6132
+ return ('0' + Math.round(__src[i] * __op + __land[i] * (1 - __op)).toString(16)).slice(-2);
6133
+ }).join('');
6134
+ } else {
6135
+ __unblended.push(id);
6136
+ }
6137
+ __expected[id] = __fill;
6138
+ app.mapSystem.highlightRegions([id], Object.assign({}, __style, { fill: __fill }));
6139
+ });
6140
+ } else {
6141
+ app.mapSystem.highlightRegions(${regionIds}, __style);
6142
+ }
6143
+ // READ BACK (gate D16, D51): every id is resolved and its colour read.
6144
+ const __r = __check(${regionIds});
6145
+ if (!__r.done.length) {
6146
+ return { success: false, notFound: __r.notFound, notApplied: __r.notApplied,
6147
+ error: 'no region was highlighted.'
6148
+ + (__r.notFound.length ? ' Not on this map: ' + __r.notFound.join(', ') + '. Ids on this map include ' + __sampleIds() + '; names work too.' : '') };
6149
+ }
6150
+ // The colour asked for must be the colour drawn (gate D59: the readback
6151
+ // reported #3b82f6 for a #e11d48 request and still said success).
6152
+ const __want = ${JSON.stringify(style.fill ?? null)};
6153
+ if (__want) {
6154
+ const __exp = function (d) { return __expected[d.asked] || __want; };
6155
+ const __off = __r.done.filter(function (d) { return String(d.fill).toLowerCase() !== String(__exp(d)).toLowerCase(); });
6156
+ if (__off.length === __r.done.length) {
6157
+ return { success: false, highlighted: __r.done, error: 'the regions were highlighted in ' + __off[0].fill + ', not the ' + __exp(__off[0]) + ' asked for.' };
6158
+ }
6159
+ if (__off.length) __r.wrongFill = __off;
6160
+ }
6161
+ const __res = { success: true, highlighted: __r.done };
6162
+ if (__unblended.length) { __res.unblended = __unblended; __res.warning = 'opacity needs a hex colour and a hex land fill; drawn at full strength: ' + __unblended.join(', ') + '.'; }
6163
+ if (__r.wrongFill) { __res.wrongFill = __r.wrongFill; __res.warning = 'some regions were not drawn in ' + __want + ': ' + __r.wrongFill.map(function (d) { return d.regionId; }).join(', ') + '.'; }
6164
+ if (__r.notFound.length) { __res.notFound = __r.notFound; __res.warning = 'not on this map, so not highlighted: ' + __r.notFound.join(', ') + '. Ids on this map include ' + __sampleIds() + '.'; }
6165
+ if (__r.notApplied.length) __res.notApplied = __r.notApplied;
6166
+ return __res;
5744
6167
  } catch (error) {
5745
6168
  return { success: false, error: error.message };
5746
6169
  }
@@ -5821,9 +6244,20 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5821
6244
  return { success: false, error: 'Map system not available' };
5822
6245
  }
5823
6246
 
5824
- try {
5825
- app.mapSystem.applyDataColors(${dataStr}, ${optionsStr});
5826
- return { success: true, regionsColored: ${Object.keys(validated.data).length} };
6247
+ try {${this.mapRegionReadbackJs()}
6248
+ if (!__features().length) return __noMap;
6249
+ const __legend = app.mapSystem.applyDataColors(${dataStr}, ${optionsStr});
6250
+ // READ BACK (gate D51): the engine skips a key it cannot resolve with a
6251
+ // console warning, which production strips, so a misspelt key looked coloured.
6252
+ const __r = __check(${JSON.stringify(Object.keys(validated.data))});
6253
+ if (!__r.done.length) {
6254
+ return { success: false, unmatched: __r.notFound,
6255
+ error: 'no region was coloured: none of the keys is on this map. Ids on this map include ' + __sampleIds() + '; names work too.' };
6256
+ }
6257
+ const __res = { success: true, regionsColored: __r.done.length, colored: __r.done };
6258
+ if (__legend && __legend.id !== undefined) __res.legendId = __legend.id;
6259
+ if (__r.notFound.length) { __res.unmatched = __r.notFound; __res.warning = 'not on this map, so not coloured: ' + __r.notFound.join(', ') + '.'; }
6260
+ return __res;
5827
6261
  } catch (error) {
5828
6262
  return { success: false, error: error.message };
5829
6263
  }
@@ -5857,8 +6291,19 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5857
6291
  }
5858
6292
 
5859
6293
  try {
5860
- const marker = app.mapSystem.addMarker(${JSON.stringify(options)});
5861
- return { success: true, markerId: marker?.id || 'marker_added' };
6294
+ // addMarker(longitude, latitude, options). One object went in as the
6295
+ // longitude, projected to NaN, and the engine returned null while this
6296
+ // reported success with the made-up id 'marker_added' (gate D51).
6297
+ const __o = ${JSON.stringify(options)};
6298
+ const marker = app.mapSystem.addMarker(__o.lon, __o.lat, __o);
6299
+ if (!marker) {
6300
+ return { success: false, error: 'no marker was added: [' + __o.lat + ', ' + __o.lon + '] (lat, lon) is outside the visible map, or no map is loaded.' };
6301
+ }
6302
+ // A REGISTRY id, so modify / animate / delete can take it. register()
6303
+ // leaves the marker inside the map group; registerItem would move it out,
6304
+ // and it would stop panning and clearing with the map.
6305
+ const markerId = app.itemRegistry.register(marker, 'mapMarker', { lat: __o.lat, lon: __o.lon, label: __o.label || null }, 'user');
6306
+ return { success: true, markerId: markerId, position: [marker.position.x, marker.position.y] };
5862
6307
  } catch (error) {
5863
6308
  return { success: false, error: error.message };
5864
6309
  }
@@ -5908,26 +6353,13 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5908
6353
  options.animate = validated.animate;
5909
6354
  if (validated.duration !== undefined)
5910
6355
  options.duration = validated.duration;
5911
- return `
5912
- // Pan map to coordinates
5913
- (function() {
5914
- if (!app.mapSystem) {
5915
- return { success: false, error: 'Map system not available' };
5916
- }
5917
-
5918
- try {
5919
- // panTo() has never existed on any studio, and there is no lat/lon pan for
5920
- // a flat map to route this to. Refused by NAME with the calls that do work,
5921
- // rather than letting the caller take an undefined-is-not-a-function.
5922
- return { success: false, error: 'pan_map is not supported: the engine has no lat/lon pan. '
5923
- + 'On a globe, rotateGlobeTo brings a coordinate to the front; on a flat map, zoom to a REGION '
5924
- + '(zoomToRegion) or call resetView for the whole view. This tool accepted coordinates and did '
5925
- + 'nothing for as long as it existed.' };
5926
- } catch (error) {
5927
- return { success: false, error: error.message };
5928
- }
5929
- })();
5930
- `.trim();
6356
+ // REFUSED, HONESTLY (gate run 2, 1.6.19). Run 1 turned pan into a camera
6357
+ // pan, which moves the studio VIEW but not the map: a canvas-framed export
6358
+ // ignores the camera, so the call reported success and the export was
6359
+ // unchanged. The map itself cannot be moved without breaking geoToCanvas
6360
+ // (it projects against the untransformed map, so markers would land in the
6361
+ // wrong place). Until the engine has a map view transform, say so.
6362
+ return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('pan'))} }; })();`;
5931
6363
  }
5932
6364
  /**
5933
6365
  * Generate code to zoom the map
@@ -5939,24 +6371,8 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5939
6371
  options.animate = validated.animate;
5940
6372
  if (validated.duration !== undefined)
5941
6373
  options.duration = validated.duration;
5942
- return `
5943
- // Set map zoom level
5944
- (function() {
5945
- if (!app.mapSystem) {
5946
- return { success: false, error: 'Map system not available' };
5947
- }
5948
-
5949
- try {
5950
- // zoomTo(level) has never existed. The engine zooms to a REGION, not to a
5951
- // numeric level, so there is nothing to convert a level into.
5952
- return { success: false, error: 'zoom_map takes a numeric level and the engine has no such call: it zooms to a REGION. '
5953
- + 'Use zoomToRegion with a region id, or resetView for the full view. A level was accepted here and did '
5954
- + 'nothing for as long as this tool existed.' };
5955
- } catch (error) {
5956
- return { success: false, error: error.message };
5957
- }
5958
- })();
5959
- `.trim();
6374
+ // Refused for the same reason as pan (see generatePanMap).
6375
+ return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('zoom'))} }; })();`;
5960
6376
  }
5961
6377
  /**
5962
6378
  * Generate code to export map configuration
@@ -6001,7 +6417,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6001
6417
 
6002
6418
  try {
6003
6419
  const result = await app.mapSystem.importCustomMap('${validated.url}', ${optionsStr});
6004
- return { success: true, ...result };
6420
+ // A summary, not the engine's return: that carries the Paper group and
6421
+ // the region Map, which serialised into a raw Paper.js dump (gate N1).
6422
+ const __r = result && result.regions;
6423
+ const __n = (__r && (__r.size ?? __r.length)) || (result && result.geoData && result.geoData.features ? result.geoData.features.length : 0);
6424
+ return { success: !!result, mapId: (result && result.mapId) || null, regions: __n, regionIds: (__r && __r.size ? Array.from(__r.keys()) : ((result && result.geoData && result.geoData.features) || []).map(function (f) { return (f.properties && f.properties.name) || f.id; })).slice(0, 50) };
6005
6425
  } catch (error) {
6006
6426
  return { success: false, error: error.message };
6007
6427
  }
@@ -6018,7 +6438,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6018
6438
 
6019
6439
  try {
6020
6440
  const result = await app.mapSystem.importCustomMap(${JSON.stringify(validated.geoJson)}, ${optionsStr});
6021
- return { success: true, ...result };
6441
+ // A summary, not the engine's return: that carries the Paper group and
6442
+ // the region Map, which serialised into a raw Paper.js dump (gate N1).
6443
+ const __r = result && result.regions;
6444
+ const __n = (__r && (__r.size ?? __r.length)) || (result && result.geoData && result.geoData.features ? result.geoData.features.length : 0);
6445
+ return { success: !!result, mapId: (result && result.mapId) || null, regions: __n, regionIds: (__r && __r.size ? Array.from(__r.keys()) : ((result && result.geoData && result.geoData.features) || []).map(function (f) { return (f.properties && f.properties.name) || f.id; })).slice(0, 50) };
6022
6446
  } catch (error) {
6023
6447
  return { success: false, error: error.message };
6024
6448
  }
@@ -6045,20 +6469,17 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6045
6469
  }
6046
6470
 
6047
6471
  try {
6048
- // getRegionAtPoint() has never existed; the engine exposes no hit test.
6049
- // canvasToGeo turns a point into COORDINATES, which is a different answer,
6050
- // so it is offered rather than quietly substituted.
6051
- if (typeof app.mapSystem.canvasToGeo === 'function') {
6052
- const coord = app.mapSystem.canvasToGeo(${validated.x}, ${validated.y});
6053
- return { success: false, coordinate: coord,
6054
- error: 'the engine has no region hit test. That point is at ' + JSON.stringify(coord)
6055
- + ' — find regions by id with get_highlighted_map_regions or getSelectedRegions.' };
6056
- }
6057
- const region = null;
6058
- if (region) {
6059
- return { success: true, regionId: region.id, regionName: region.name, properties: region.properties };
6060
- }
6061
- return { success: true, regionId: null, message: 'No region at this point' };
6472
+ if (!app.mapSystem.currentMap) {
6473
+ return { success: false, error: 'no map is loaded. Load one with pinepaper_map {action: "load"} first.' };
6474
+ }
6475
+ // canvasToGeo takes ONE [x, y] argument; (x, y) handed it a bare number.
6476
+ const coord = typeof app.mapSystem.canvasToGeo === 'function' ? app.mapSystem.canvasToGeo([${validated.x}, ${validated.y}]) : null;
6477
+ // The hit test the studio's own hover uses (getRegionAtPoint never existed).
6478
+ const f = app.mapSystem._findFeatureAtCanvasPoint(${validated.x}, ${validated.y});
6479
+ if (!f) return { success: true, regionId: null, coordinate: coord, message: 'no region at this point (off the map, or ocean)' };
6480
+ const p = f.properties || {};
6481
+ return { success: true, regionId: f._mapId !== undefined ? f._mapId : (p.id || p.ISO_A3 || p.name || null),
6482
+ regionName: f._mapName || p.name || p.NAME || null, coordinate: coord, properties: p };
6062
6483
  } catch (error) {
6063
6484
  return { success: false, error: error.message };
6064
6485
  }
@@ -6111,7 +6532,8 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6111
6532
  const result = app.mapSystem.animateRegionsWave({
6112
6533
  duration: ${validated.duration || 10},
6113
6534
  loop: ${validated.loop !== false},
6114
- colors: ${colorsStr},
6535
+ colors: ${colorsStr},${validated.waveSpeed !== undefined ? `
6536
+ waveSpeed: ${validated.waveSpeed},` : ''}
6115
6537
  waveDirection: '${validated.waveDirection || 'horizontal'}'
6116
6538
  });
6117
6539
  return { success: true, animatedRegions: result?.animatedRegions || [], totalRegions: result?.totalRegions || 0 };
@@ -6277,9 +6699,17 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6277
6699
  return { success: false, error: 'Map system not available' };
6278
6700
  }
6279
6701
 
6280
- try {
6281
- const result = app.mapSystem.getHighlightedRegions();
6282
- return { highlighted: result?.highlighted || [], count: result?.count || 0 };
6702
+ try {${this.mapRegionReadbackJs()}
6703
+ // getHighlightedRegions returns an ARRAY of {regionId, name, path}; this
6704
+ // read result.highlighted off it and always answered [] (gate D51). It also
6705
+ // walks regionPaths, empty on a rasterised map, so the override channel is
6706
+ // read as well.
6707
+ const __byId = new Map();
6708
+ (app.mapSystem.getHighlightedRegions() || []).forEach(function (r) { __byId.set(r.regionId, { regionId: r.regionId, name: r.name }); });
6709
+ __overridden().forEach(function (r) { __byId.set(r.regionId, r); });
6710
+ const __list = Array.from(__byId.values());
6711
+ return { success: true, highlighted: __list, count: __list.length,
6712
+ note: 'highlight and apply_colors share one colour channel, so this lists every region either has coloured.' };
6283
6713
  } catch (error) {
6284
6714
  return { success: false, error: error.message };
6285
6715
  }
@@ -6481,6 +6911,91 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6481
6911
  /**
6482
6912
  * Generate code to export the complete scene state
6483
6913
  */
6914
+ /**
6915
+ * pinepaper_place_on_surface (FxTool #56): app.placeOnSurface corner-pins
6916
+ * the UI into the photo's screen, mattes it so a hand stays in front, and
6917
+ * BAKES it into the photo's pixels. Async. Only plain fields come back.
6918
+ */
6919
+ generatePlaceOnSurface(input) {
6920
+ // Named, not spread: a spread makes every field look consumed, which is
6921
+ // how an accepted-and-ignored option hides (the guard test reads these).
6922
+ const opts = {};
6923
+ if (input.quad !== undefined)
6924
+ opts.quad = input.quad;
6925
+ if (input.quadSpace !== undefined)
6926
+ opts.quadSpace = input.quadSpace;
6927
+ if (input.occlusion !== undefined)
6928
+ opts.occlusion = input.occlusion;
6929
+ if (input.key !== undefined)
6930
+ opts.key = input.key;
6931
+ if (input.erode !== undefined)
6932
+ opts.erode = input.erode;
6933
+ if (input.matteBlur !== undefined)
6934
+ opts.matteBlur = input.matteBlur;
6935
+ if (input.soften !== undefined)
6936
+ opts.soften = input.soften;
6937
+ if (input.glare !== undefined)
6938
+ opts.glare = input.glare;
6939
+ if (input.spill !== undefined)
6940
+ opts.spill = input.spill;
6941
+ if (input.spillRadius !== undefined)
6942
+ opts.spillRadius = input.spillRadius;
6943
+ if (input.tint !== undefined)
6944
+ opts.tint = input.tint;
6945
+ if (input.slot !== undefined)
6946
+ opts.slot = input.slot;
6947
+ if (input.hideSource !== undefined)
6948
+ opts.hideSource = input.hideSource;
6949
+ const { photoId, sourceId } = input;
6950
+ return `
6951
+ // Place a UI onto a screen in a photo
6952
+ (async function() {
6953
+ if (typeof app.placeOnSurface !== 'function') { return { success: false, error: 'app.placeOnSurface unavailable — update FxTool' }; }
6954
+ const r = await app.placeOnSurface(${JSON.stringify(photoId)}, ${JSON.stringify(sourceId)}, ${JSON.stringify(opts)});
6955
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the UI could not be placed' }; }
6956
+ return { success: true, photoId: r.id, slot: r.slot, quad: r.quad, detected: r.detected || null,
6957
+ key: r.key, matteCoverage: r.matteCoverage, ...(r.lostSlots && r.lostSlots.length ? { lostSlots: r.lostSlots } : {}) };
6958
+ })();`.trim();
6959
+ }
6960
+ /**
6961
+ * A RESTORABLE project document (D28): app.captureProjectDocument, the
6962
+ * engine's own durable format (core/ProjectDocument.js), which carries
6963
+ * relations, rigging, connectors, scene chains and animation config.
6964
+ * NOT exportProjectJSON: the engine documents that one as a bare Paper tree
6965
+ * that drops roughly half of a non-trivial scene.
6966
+ */
6967
+ generateCaptureProject(name) {
6968
+ return `
6969
+ // Capture a restorable project document
6970
+ (function() {
6971
+ if (typeof app.captureProjectDocument !== 'function') { return { success: false, error: 'app.captureProjectDocument unavailable — update FxTool' }; }
6972
+ ${SCENE_COUNTS_JS}
6973
+ const doc = app.captureProjectDocument(${JSON.stringify(name ? { name } : {})});
6974
+ // What the document CANNOT carry (D29): render hooks added as raw functions
6975
+ // and frame callbacks from generated code. Named, never dropped in silence.
6976
+ const __un = (doc && Array.isArray(doc.unserialized)) ? doc.unserialized
6977
+ : (app.listUnserializedCallbacks ? app.listUnserializedCallbacks() : []);
6978
+ return { success: true, json: JSON.stringify(doc), counts: __ppSceneCounts(), unserialized: __un };
6979
+ })();`.trim();
6980
+ }
6981
+ /** Load a staged project document (window.__ppStage.projectDoc), replacing the scene. */
6982
+ generateLoadProject(strict) {
6983
+ return `
6984
+ // Load a project document, replacing the current scene
6985
+ (function() {
6986
+ if (typeof app.loadProjectDocument !== 'function') { return { success: false, error: 'app.loadProjectDocument unavailable — update FxTool' }; }
6987
+ ${SCENE_COUNTS_JS}
6988
+ const text = window.__ppStage && window.__ppStage.projectDoc;
6989
+ if (typeof text !== 'string' || !text) { return { success: false, error: 'no project document was staged' }; }
6990
+ // Second guard, in the page: nothing but a project document reaches the loader.
6991
+ let __doc = null;
6992
+ try { __doc = JSON.parse(text); } catch (e) { return { success: false, error: 'not a PinePaper scene file (not JSON); nothing was changed' }; }
6993
+ if (!__doc || __doc.kind !== 'pinepaper.project') { return { success: false, error: 'not a PinePaper scene file (no kind "pinepaper.project"); nothing was changed' }; }
6994
+ const r = app.loadProjectDocument(__doc, { strict: ${strict ? 'true' : 'false'} });
6995
+ if (!r || r.ok === false) { return { success: false, error: 'the document was refused: ' + ((r && r.errors && r.errors.join('; ')) || 'invalid project document'), warnings: (r && r.warnings) || [] }; }
6996
+ return { success: true, counts: __ppSceneCounts(), warnings: r.warnings || [], meta: r.meta || null };
6997
+ })();`.trim();
6998
+ }
6484
6999
  generateExportScene() {
6485
7000
  return `
6486
7001
  // Export complete scene state
@@ -6592,7 +7107,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6592
7107
  backgroundColor: backgroundColor,
6593
7108
  canvasSize: canvasSize,
6594
7109
  itemCount: items.length,
6595
- relationCount: relations.length
7110
+ relationCount: relations.length,
7111
+ // NOT A BACKUP (D28). This summary carries positions and bounds, not
7112
+ // keyframe tracks, rigging or scene chains: a scene saved this way and
7113
+ // rebuilt from it has no motion. Said in the result, not only in docs.
7114
+ note: 'summary for inspection: animation tracks, rigging and scene chains are not included and this cannot be restored. To save a restorable scene: pinepaper_export_scene with full: true, then pinepaper_import_scene.'
6596
7115
  };
6597
7116
  } catch (error) {
6598
7117
  return { success: false, error: error.message };
@@ -6693,29 +7212,32 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6693
7212
  try {
6694
7213
  // Import the Paper.js item from JSON
6695
7214
  const item = paper.project.importJSON(${itemJsonStr});
6696
- if (!item) {
6697
- return { success: false, error: 'Failed to import item from JSON' };
7215
+ // importJSON accepts anything: { bogus: true } came back as an object with
7216
+ // no bounds and was registered as an item (found by the 1.6.19 gate). Only
7217
+ // a real Paper item with geometry may be registered.
7218
+ if (!item || !(item instanceof paper.Item) || !item.bounds) {
7219
+ try { if (item && typeof item.remove === 'function') item.remove(); } catch (e) {}
7220
+ return { success: false, error: 'itemJson is not a Paper.js item (pass the output of item.exportJSON())' };
7221
+ }
7222
+
7223
+ // REGISTER THROUGH THE ENGINE (gate H1, 1.6.19). This used to call
7224
+ // app.itemRegistry.set(), which does not exist ("not a function", nothing
7225
+ // registered), with an id invented from .size (undefined, so 'item_NaN',
7226
+ // or a collision), and it moved every item into the TEXT group. The
7227
+ // registry's own register() mints the id, never steals a live one, treats
7228
+ // re-registration as an update, and returns the id it assigned.
7229
+ // importJSON already put the item in the active layer.
7230
+ if (!app.itemRegistry || typeof app.itemRegistry.register !== 'function') {
7231
+ try { item.remove(); } catch (e) {}
7232
+ return { success: false, error: 'app.itemRegistry.register unavailable — update FxTool' };
6698
7233
  }
6699
-
6700
- // Register in item registry
6701
- const itemId = app.itemRegistry ? 'item_' + (app.itemRegistry.size + 1) : 'item_1';
6702
- item.data = item.data || {};
6703
- item.data.registryId = itemId;
6704
- item.data.type = ${JSON.stringify(input.itemType)};
6705
-
6706
- // Merge custom properties
6707
7234
  const customProps = ${propertiesStr};
6708
- Object.assign(item.data, customProps);
6709
-
6710
- // Add to registry
6711
- if (app.itemRegistry) {
6712
- app.itemRegistry.set(itemId, item);
6713
- }
6714
-
6715
- // Add to appropriate group
6716
- if (app.textItemGroup) {
6717
- app.textItemGroup.addChild(item);
7235
+ const itemId = app.itemRegistry.register(item, ${JSON.stringify(input.itemType)}, customProps, 'import');
7236
+ if (!itemId) {
7237
+ try { item.remove(); } catch (e) {}
7238
+ return { success: false, error: 'the registry refused the item' };
6718
7239
  }
7240
+ try { if (paper.view) paper.view.update(); } catch (e) {}
6719
7241
 
6720
7242
  return {
6721
7243
  success: true,
@@ -6997,7 +7519,9 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6997
7519
  // Get available mask types
6998
7520
  (function() {
6999
7521
  return {
7000
- maskTypes: ['rectangle', 'circle', 'ellipse', 'star', 'triangle', 'hexagon', 'heart', 'rounded', 'custom']
7522
+ // From the enum the mask tools validate against: this listed 'custom',
7523
+ // which both refuse (1.6.19 gate).
7524
+ maskTypes: ${JSON.stringify(MaskTypeSchema.options)}
7001
7525
  };
7002
7526
  })();
7003
7527
  `.trim();
@@ -7007,14 +7531,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7007
7531
  // Get available mask animation presets
7008
7532
  (function() {
7009
7533
  return {
7010
- animations: [
7011
- 'wipeLeft', 'wipeRight', 'wipeUp', 'wipeDown',
7012
- 'iris', 'irisOut',
7013
- 'star', 'heart',
7014
- 'curtainHorizontal', 'curtainVertical', 'cinematic',
7015
- 'diagonalWipe',
7016
- 'revealUp', 'revealDown'
7017
- ]
7534
+ animations: ${JSON.stringify(MaskPresetSchema.options)}
7018
7535
  };
7019
7536
  })();
7020
7537
  `.trim();
@@ -7028,10 +7545,12 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7028
7545
  const categoryFilter = category ? `'${category}'` : 'null';
7029
7546
  return `
7030
7547
  // List available templates
7031
- (function() {
7548
+ (async function() {
7032
7549
  if (!app.templateManager) {
7033
7550
  return { error: 'Template manager not available. Make sure PinePaper Studio is loaded.' };
7034
7551
  }
7552
+ // The built-in catalogue loads lazily; a fresh page listed none (1.6.19 gate).
7553
+ try { if (app.templateManager._ensureTemplateData) await app.templateManager._ensureTemplateData(); } catch (e) {}
7035
7554
  const allTemplates = app.templateManager.getAllTemplates();
7036
7555
  const category = ${categoryFilter};
7037
7556
  const filtered = category
@@ -7056,8 +7575,25 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7056
7575
  if (!app.templateManager) {
7057
7576
  return { error: 'Template manager not available. Make sure PinePaper Studio is loaded.' };
7058
7577
  }
7578
+ // AN UNKNOWN ID IS AN ERROR, NOT A SUCCESS (gate C1, 1.6.19). loadTemplate
7579
+ // on an id that does not exist changed nothing and this still reported
7580
+ // "Template applied successfully"; the description's own example id
7581
+ // ('solar-system') was one of them. The nearest real ids are offered.
7582
+ const __tid = ${JSON.stringify(templateId)};
7583
+ // The built-in catalogue loads LAZILY: on a fresh page getAllTemplates() is
7584
+ // [] until it does, which made every id look unknown. Load it first, as
7585
+ // loadTemplate itself does, and count the cloud templates it falls back to.
7586
+ try { if (app.templateManager._ensureTemplateData) await app.templateManager._ensureTemplateData(); } catch (e) {}
7587
+ const __cloud = (app.templateManager.cloudSync && app.templateManager.cloudSync.cloudTemplates) || [];
7588
+ const __all = app.templateManager.getAllTemplates().concat(__cloud.filter(Boolean));
7589
+ if (__all.length && !__all.some(function(t) { return t && t.id === __tid; })) {
7590
+ const __lev = function(a, b) { const m = a.length, n = b.length; const d = Array.from({ length: m + 1 }, function(_, i) { return [i]; }); for (let j = 1; j <= n; j++) d[0][j] = j; for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) d[i][j] = Math.min(d[i-1][j] + 1, d[i][j-1] + 1, d[i-1][j-1] + (a[i-1] === b[j-1] ? 0 : 1)); return d[m][n]; };
7591
+ const __near = __all.map(function(t) { const id = String(t.id); return { id: id, d: id.indexOf(__tid) >= 0 || __tid.indexOf(id) >= 0 ? 0 : __lev(__tid, id) }; })
7592
+ .sort(function(a, b) { return a.d - b.d; }).slice(0, 5).map(function(x) { return x.id; });
7593
+ return { success: false, error: 'no template "' + __tid + '". Nearest: ' + __near.join(', ') + '. List them with listOnly: true.' };
7594
+ }
7059
7595
  try {
7060
- await app.templateManager.loadTemplate('${templateId}', true);
7596
+ await app.templateManager.loadTemplate(__tid, true);
7061
7597
 
7062
7598
  // DID THE CLIPPED CHARACTER PARTS SURVIVE?
7063
7599
  //
@@ -7356,13 +7892,22 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7356
7892
  opts.push(`defaultTransition: ${JSON.stringify(input.defaultTransition)}`);
7357
7893
  if (input.transitionDuration !== undefined)
7358
7894
  opts.push(`transitionDuration: ${input.transitionDuration * 1000}`);
7895
+ if (input.cutSound !== undefined)
7896
+ opts.push(`cutSound: ${JSON.stringify(input.cutSound)}`);
7359
7897
  const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : '';
7360
7898
  return `
7361
7899
  // Create scene chain for sequential playback
7362
7900
  (function() {
7363
7901
  ${guard}
7364
- app.sceneManager.createChain(${JSON.stringify(input.sceneIds)}${optsStr});
7365
- return { success: true, action: 'create_chain', sceneIds: ${JSON.stringify(input.sceneIds)}${input.loop !== undefined ? `, loop: ${input.loop}` : ''}${input.defaultDuration !== undefined ? `, defaultDuration: ${input.defaultDuration}` : ''} };
7902
+ const __cr = app.sceneManager.createChain(${JSON.stringify(input.sceneIds)}${optsStr});
7903
+ // An unknown cut sound comes back {ok:false, reason} (FxTool D85); a
7904
+ // studio without cut sounds ignores the option, which the cuts readback shows.
7905
+ if (__cr && __cr.ok === false) { return { success: false, action: 'create_chain', error: __cr.reason || 'the chain was refused' }; }
7906
+ const __res = { success: true, action: 'create_chain', sceneIds: ${JSON.stringify(input.sceneIds)}${input.loop !== undefined ? `, loop: ${input.loop}` : ''}${input.defaultDuration !== undefined ? `, defaultDuration: ${input.defaultDuration}` : ''} };
7907
+ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.sceneManager.getChainCuts ? app.sceneManager.getChainCuts() : null);
7908
+ if (__cuts) __res.cuts = __cuts;
7909
+ else __res.warning = 'this studio placed no cut sounds: it predates them (FxTool D85). The chain plays without them.';
7910
+ ` : ''} return __res;
7366
7911
  })();
7367
7912
  `.trim();
7368
7913
  }
@@ -7714,7 +8259,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7714
8259
  for (const id of ids) {
7715
8260
  const entry = app.itemRegistry.get(id);
7716
8261
  if (entry && entry.item) {
7717
- ${mode === 'remove' ? 'entry.item.selected = false;' : 'app.select(entry.item);'}
8262
+ ${mode === 'remove' ? 'entry.item.selected = false;' : 'app.select(entry.item, false);'}
7718
8263
  selected.push(id);
7719
8264
  }
7720
8265
  }
@@ -7725,8 +8270,9 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7725
8270
  return `
7726
8271
  // Select all items
7727
8272
  (function() {
8273
+ ${REG_ID_JS}
7728
8274
  app.selectAll();
7729
- const items = app.getSelectedItems().map(i => i.data?.itemId || i.name || i.id);
8275
+ const items = app.getSelectedItems().map(i => __ppRegId(i)).filter(Boolean);
7730
8276
  return { success: true, action: 'select_all', count: items.length, items };
7731
8277
  })();`.trim();
7732
8278
  case 'deselect_all':
@@ -7740,8 +8286,9 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7740
8286
  return `
7741
8287
  // Get current selection
7742
8288
  (function() {
8289
+ ${REG_ID_JS}
7743
8290
  const items = app.getSelectedItems().map(i => ({
7744
- itemId: i.data?.itemId || i.name || i.id,
8291
+ itemId: __ppRegId(i),
7745
8292
  type: i.data?.itemType || i.className,
7746
8293
  bounds: i.bounds ? { x: i.bounds.x, y: i.bounds.y, width: i.bounds.width, height: i.bounds.height } : null
7747
8294
  }));
@@ -7751,8 +8298,9 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7751
8298
  return `
7752
8299
  // Delete selected items
7753
8300
  (function() {
8301
+ ${REG_ID_JS}
7754
8302
  const selection = app.getSelectedItems();
7755
- const deleted = selection.map(i => i.data?.itemId || i.name || i.id);
8303
+ const deleted = selection.map(i => __ppRegId(i)).filter(Boolean);
7756
8304
  app.deleteSelected();
7757
8305
  return { success: true, action: 'delete_selected', deleted, count: deleted.length };
7758
8306
  })();`.trim();
@@ -7787,6 +8335,32 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7787
8335
  })();`.trim();
7788
8336
  }
7789
8337
  generateBrandKit(input) {
8338
+ // THE ACTIVE BRAND (D69). apply recolours what is on the canvas once; a
8339
+ // later beat's new items knew nothing of it. 'set' applies AND makes the
8340
+ // kit the studio's active brand, which every create reads (withBrandDefaults).
8341
+ // It lives in the PAGE, not this process: one cloud server serves many
8342
+ // studios, and a brand must not cross between them.
8343
+ if (input.action === 'get' || input.action === 'clear') {
8344
+ return `
8345
+ // Brand kit: ${input.action} the active brand
8346
+ (function() {
8347
+ const b = (typeof window !== 'undefined' && window.__ppActiveBrand) || null;
8348
+ ${input.action === 'clear' ? 'if (typeof window !== "undefined") window.__ppActiveBrand = null;' : ''}
8349
+ return { success: true, action: ${JSON.stringify(input.action)}, ${input.action === 'clear' ? 'cleared: !!b' : 'brand: b'} };
8350
+ })();`.trim();
8351
+ }
8352
+ if (input.action === 'set') {
8353
+ const applied = this.generateBrandKit({ ...input, action: 'apply' });
8354
+ return `
8355
+ // Brand kit: apply, then make it the active brand every new item inherits
8356
+ (async function() {
8357
+ const out = await ${applied.replace(/^\/\/[^\n]*\n/, '').replace(/;\s*$/, '')};
8358
+ if (out && (out.success === false || out.ok === false)) return out;
8359
+ if (typeof window !== 'undefined') window.__ppActiveBrand = ${JSON.stringify(input.kit)};
8360
+ return Object.assign({}, out, { success: true, action: 'set', active: true,
8361
+ note: 'new shapes take colors.primary and new text colors.text with the heading (32 px and up) or body font, wherever the call names none; noBrand: true opts an item out. The active brand lives with this studio session: set it again after a reload.' });
8362
+ })();`.trim();
8363
+ }
7790
8364
  const opts = JSON.stringify({ ...(input.selectionOnly ? { selectionOnly: true } : {}) });
7791
8365
  const method = input.action === 'plan' ? 'planBrandKit' : 'applyBrandKit';
7792
8366
  const facade = this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
@@ -7931,7 +8505,7 @@ ${loads}
7931
8505
  case 'lineage': return this._facadeCall('getLineage', id, 'Provenance: lineage');
7932
8506
  case 'dependents': return this._facadeCall('getDependents', id, 'Provenance: dependents');
7933
8507
  case 'record':
7934
- return this._facadeCall('recordLineage', `${id}, ${JSON.stringify(input.kind || 'derived')}, ${JSON.stringify(input.sourceRef || '')}, ${JSON.stringify(input.meta || {})}`, 'Provenance: record lineage');
8508
+ return this._facadeCall('recordLineage', `${id}, ${JSON.stringify(input.kind || 'derivedFrom')}, ${JSON.stringify(input.sourceRef || '')}, ${JSON.stringify(input.meta || {})}`, 'Provenance: record lineage');
7935
8509
  }
7936
8510
  }
7937
8511
  generateCompose(input) {
@@ -7953,27 +8527,46 @@ ${loads}
7953
8527
  // vector and audio halves of composeCollage were unreachable through
7954
8528
  // MCP, so agents converged on the four static patterns the description
7955
8529
  // happened to name. A capability gap reads as model bias from outside.
7956
- return this._facadeCall('composeCollage', `${JSON.stringify(input.pattern || '')}, ${JSON.stringify(input.itemIds || [])}, ${JSON.stringify({
7957
- ...(input.treatment ? { treatment: input.treatment } : {}),
7958
- ...(input.applyCamera === false ? { applyCamera: false } : {}),
7959
- ...(input.loop ? { loop: true } : {}),
7960
- ...(input.craft ? { craft: input.craft } : {}),
7961
- ...(input.reveal !== undefined ? { reveal: input.reveal } : {}),
7962
- ...(input.revealOptions ? { revealOptions: input.revealOptions } : {}),
7963
- ...(input.style ? { style: input.style } : {}),
7964
- ...(input.text ? { text: input.text } : {}),
7965
- ...(input.assets ? { assets: input.assets } : {}),
7966
- ...(input.audio ? { audio: input.audio } : {}),
7967
- ...(input.grid ? { grid: true } : {}),
7968
- // The design axes. A register resolves the craft (gutter, margin,
7969
- // hue budget, type scale); a medium says what makes the marks and
7970
- // is REFUSED when a composition cannot be rendered in it.
7971
- ...(input.register ? { register: input.register } : {}),
7972
- ...(input.level !== undefined ? { level: input.level } : {}),
7973
- ...(input.medium ? { medium: input.medium } : {}),
7974
- ...(input.stitch ? { stitch: input.stitch } : {}),
7975
- ...(input.stitchBudget !== undefined ? { stitchBudget: input.stitchBudget } : {}),
7976
- })}`, 'Compose: apply pattern');
8530
+ {
8531
+ const apply = this._facadeCall('composeCollage', `${JSON.stringify(input.pattern || '')}, ${JSON.stringify(input.itemIds || [])}, ${JSON.stringify({
8532
+ ...(input.treatment ? { treatment: input.treatment } : {}),
8533
+ ...(input.applyCamera === false ? { applyCamera: false } : {}),
8534
+ ...(input.loop ? { loop: true } : {}),
8535
+ ...(input.craft ? { craft: input.craft } : {}),
8536
+ ...(input.reveal !== undefined ? { reveal: input.reveal } : {}),
8537
+ ...(input.revealOptions ? { revealOptions: input.revealOptions } : {}),
8538
+ ...(input.style ? { style: input.style } : {}),
8539
+ ...(input.text ? { text: input.text } : {}),
8540
+ ...(input.assets ? { assets: input.assets } : {}),
8541
+ ...(input.audio ? { audio: input.audio } : {}),
8542
+ ...(input.grid ? { grid: true } : {}),
8543
+ // The design axes. A register resolves the craft (gutter, margin,
8544
+ // hue budget, type scale); a medium says what makes the marks and
8545
+ // is REFUSED when a composition cannot be rendered in it.
8546
+ ...(input.register ? { register: input.register } : {}),
8547
+ ...(input.level !== undefined ? { level: input.level } : {}),
8548
+ ...(input.medium ? { medium: input.medium } : {}),
8549
+ ...(input.stitch ? { stitch: input.stitch } : {}),
8550
+ ...(input.stitchBudget !== undefined ? { stitchBudget: input.stitchBudget } : {}),
8551
+ })}`, 'Compose: apply pattern');
8552
+ ;
8553
+ if (typeof input.reveal !== 'string' || !input.reveal)
8554
+ return apply;
8555
+ // CHECK THE REVEAL FIRST (gate C3). composeCollage lays the items out
8556
+ // and wires their relations BEFORE it reaches an unknown reveal, so a
8557
+ // typo errored with the grid already applied and nothing reverted.
8558
+ return `
8559
+ // Compose: check the reveal, then apply
8560
+ (async function() {
8561
+ if (typeof app.listReveals === 'function') {
8562
+ const __keys = (app.listReveals() || []).map(function (r) { return r && r.key; });
8563
+ if (__keys.length && __keys.indexOf(${JSON.stringify(input.reveal)}) === -1) {
8564
+ return { success: false, error: 'unknown reveal ' + ${JSON.stringify(JSON.stringify(input.reveal))} + ' — known: ' + __keys.join(', ') + '. Nothing was changed.' };
8565
+ }
8566
+ }
8567
+ return await ${apply.replace(/^\/\/[^\n]*\n/, '').replace(/;\s*$/, '')};
8568
+ })();`.trim();
8569
+ }
7977
8570
  }
7978
8571
  }
7979
8572
  generateAudioBeats(input) {
@@ -8292,8 +8885,8 @@ ${resolveSource}
8292
8885
  const resolve = ` const item = (typeof app._resolveRaster === 'function')
8293
8886
  ? app._resolveRaster(${JSON.stringify(input.itemId)})
8294
8887
  : (app.itemRegistry ? app.itemRegistry.getItem(${JSON.stringify(input.itemId)}) : null);
8295
- if (!item) return { error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
8296
- if (typeof app.applyImageFilter !== 'function') return { error: 'GPU image filters unavailable — update FxTool' };`;
8888
+ if (!item) return { success: false, error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
8889
+ if (typeof app.applyImageFilter !== 'function') return { success: false, error: 'GPU image filters unavailable — update FxTool' };`;
8297
8890
  switch (input.action) {
8298
8891
  case 'apply':
8299
8892
  return `
@@ -8310,7 +8903,7 @@ ${resolve}
8310
8903
  }
8311
8904
  return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
8312
8905
  } catch (e) {
8313
- return { error: 'Failed to apply filter: ' + e.message };
8906
+ return { success: false, action: 'apply', error: 'Failed to apply filter: ' + e.message };
8314
8907
  }
8315
8908
  })();`.trim();
8316
8909
  case 'chain':
@@ -8323,7 +8916,7 @@ ${resolve}
8323
8916
  await app.applyImageFilterChain(item, filters);
8324
8917
  return { success: true, action: 'chain', itemId: ${JSON.stringify(input.itemId)}, filterCount: filters.length };
8325
8918
  } catch (e) {
8326
- return { error: 'Failed to apply filter chain: ' + e.message };
8919
+ return { success: false, action: 'chain', error: 'Failed to apply filter chain: ' + e.message };
8327
8920
  }
8328
8921
  })();`.trim();
8329
8922
  default:
@@ -8624,16 +9217,28 @@ ${resolve}
8624
9217
  return `
8625
9218
  // Clear background
8626
9219
  (function() {
9220
+ // clearBackground() removes generated patterns, drawings and the image, but
9221
+ // not the colour; the tool promises "remove entirely" and left #1e293b
9222
+ // behind (gate C6, 1.6.19). The colour goes too, to transparent.
8627
9223
  app.clearBackground();
8628
- return { success: true, action: 'clear' };
9224
+ if (typeof app.setBackgroundColor === 'function') app.setBackgroundColor('transparent');
9225
+ return { success: true, action: 'clear', removed: ['pattern', 'drawing', 'image', 'color'] };
8629
9226
  })();`.trim();
8630
9227
  case 'get':
8631
9228
  return `
8632
9229
  // Get background info
8633
9230
  (function() {
8634
- // FxTool exposes getBackgroundMode() returning the mode string only.
9231
+ // getBackgroundMode() returns only the mode, so 'color' came back with no
9232
+ // colour (gate C6, 1.6.19). The colour is read from the canvas, as hex.
8635
9233
  const mode = app.getBackgroundMode ? app.getBackgroundMode() : null;
8636
- return { success: true, action: 'get', mode };
9234
+ const css = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || '';
9235
+ // No regex: this is a template literal, and \\( or \\s here reach the page
9236
+ // without their backslash (that was the #NaN293b).
9237
+ const open = css.indexOf('('), close = css.indexOf(')');
9238
+ let color = css || null;
9239
+ if (css.indexOf('rgb') === 0 && open > 0 && close > open) { const p = css.slice(open + 1, close).split(',').join(' ').split('/').join(' ').split(' ').filter(Boolean).map(function (x) { return parseFloat(x); }); color = (p.length > 3 && p[3] === 0) ? 'transparent' : '#' + p.slice(0, 3).map(function (v) { return Math.round(v).toString(16).padStart(2, '0'); }).join(''); }
9240
+ const patterns = app.patternGroup && app.patternGroup.children ? app.patternGroup.children.length : 0;
9241
+ return { success: true, action: 'get', mode, color, hasPattern: patterns > 0, hasImage: !!(app.config && app.config.backgroundImage) };
8637
9242
  })();`.trim();
8638
9243
  default:
8639
9244
  return `(function() { return { error: 'Unknown background action: ${input.action}' }; })();`;
@@ -8669,13 +9274,14 @@ ${resolve}
8669
9274
  return `
8670
9275
  // Hit test at point
8671
9276
  (function() {
9277
+ ${REG_ID_JS}
8672
9278
  const point = new paper.Point(${input.x ?? 0}, ${input.y ?? 0});
8673
9279
  ${all
8674
9280
  ? `const results = app.hitTestAll(point, { tolerance: ${tolerance} });
8675
9281
  const hits = results.map(r => ({
8676
- itemId: r.item?.data?.itemId || r.item?.name || r.item?.id,
8677
- type: r.item?.data?.itemType || r.item?.className,
8678
- point: { x: r.point?.x, y: r.point?.y }
9282
+ itemId: __ppRegId(r.item),
9283
+ type: r.item?.data?.type || r.item?.className,
9284
+ point: r.point ? { x: r.point.x, y: r.point.y } : null
8679
9285
  }));
8680
9286
  return { success: true, action: 'hit_test', x: ${input.x ?? 0}, y: ${input.y ?? 0}, hits, count: hits.length };`
8681
9287
  : `const result = app.hitTest(point, { tolerance: ${tolerance} });
@@ -8683,7 +9289,7 @@ ${resolve}
8683
9289
  return {
8684
9290
  success: true, action: 'hit_test',
8685
9291
  x: ${input.x ?? 0}, y: ${input.y ?? 0},
8686
- hit: { itemId: result.item?.data?.itemId || result.item?.name || result.item?.id, type: result.item?.data?.itemType || result.item?.className }
9292
+ hit: { itemId: __ppRegId(result.item), type: result.item?.data?.type || result.item?.className, point: result.point ? { x: result.point.x, y: result.point.y } : null }
8687
9293
  };`}
8688
9294
  })();`.trim();
8689
9295
  }
@@ -9083,12 +9689,47 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9083
9689
  ${guard}
9084
9690
  const entry = app.itemRegistry.get(${itemIdStr});
9085
9691
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
9086
- app.interactionSystem.removeContinuousBehavior(entry.item, ${behaviorIdStr});
9087
- return { success: true, action: 'remove_behavior', itemId: ${itemIdStr}, behaviorId: ${behaviorIdStr} };
9692
+ // The engine's remove returns nothing, and this used to echo its inputs, so
9693
+ // a wrong behaviorId still "succeeded" (gate run 2). Count what went.
9694
+ const __list = function () { const m = app.interactionSystem.continuousBehaviors; const l = m && m.get ? m.get(${itemIdStr}) : null; return (l || []).map(function (b) { return b.id; }); };
9695
+ const __before = __list();
9696
+ const __bid = ${behaviorIdStr};
9697
+ if (__bid && __before.indexOf(__bid) === -1) {
9698
+ return { success: false, error: 'no behavior ' + JSON.stringify(__bid) + ' on ' + ${itemIdStr} + (__before.length ? '; it has: ' + __before.join(', ') : '; it has none') + '. Use the behaviorId add_behavior returned, or omit it to remove all.' };
9699
+ }
9700
+ if (__bid) app.interactionSystem.removeContinuousBehavior(entry.item, __bid);
9701
+ else {
9702
+ // "Remove all" means the behaviors a caller ADDED. The engine's own
9703
+ // selectable / draggable go too with a bare remove, which left the item
9704
+ // impossible to select or drag (gate run 3, N4).
9705
+ const __sys = ['selectable', 'draggable'];
9706
+ const __m = app.interactionSystem.continuousBehaviors;
9707
+ ((__m && __m.get ? __m.get(${itemIdStr}) : null) || []).slice()
9708
+ .filter(function (b) { return __sys.indexOf(b.type) === -1; })
9709
+ .forEach(function (b) { app.interactionSystem.removeContinuousBehavior(entry.item, b.id); });
9710
+ }
9711
+ const __after = __list();
9712
+ const removed = __before.filter(function (id) { return __after.indexOf(id) === -1; });
9713
+ return { success: removed.length > 0, action: 'remove_behavior', itemId: ${itemIdStr}, removed: removed, remaining: __after, ...(removed.length ? {} : { error: 'nothing was removed: the item has no behaviors added to it (the built-in selectable / draggable are kept)' }) };
9088
9714
  })();`.trim();
9089
9715
  }
9090
9716
  case 'trigger_action': {
9091
- const params = JSON.stringify(input.params || {});
9717
+ // The engine's handlers read params.items (ids) for show / hide /
9718
+ // animate / stopAnimation, and params.points for incrementScore. The
9719
+ // tool documented neither, so { itemId } hid nothing and { amount: 5 }
9720
+ // scored 1 (gate D13, 1.6.19). The natural names are accepted too.
9721
+ const raw = { ...(input.params || {}) };
9722
+ if (raw.items === undefined) {
9723
+ const one = raw.itemId ?? raw.targetItemId;
9724
+ const many = raw.itemIds;
9725
+ if (Array.isArray(many))
9726
+ raw.items = many;
9727
+ else if (typeof one === 'string')
9728
+ raw.items = [one];
9729
+ }
9730
+ if (input.actionType === 'incrementScore' && raw.points === undefined && typeof raw.amount === 'number')
9731
+ raw.points = raw.amount;
9732
+ const params = JSON.stringify(raw);
9092
9733
  return `
9093
9734
  // Trigger interaction action
9094
9735
  (function() {
@@ -9460,8 +10101,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9460
10101
  // Toggle grid
9461
10102
  (function() {
9462
10103
  ${guard}
9463
- // showGrid()/hideGrid(), not a boolean setter — setGridVisible never existed.
9464
- app.measurementSystem[${enabled} ? 'showGrid' : 'hideGrid']();
10104
+ // setUnitGridEnabled shows/hides the grid AND records the setting that
10105
+ // set_snap depends on; showGrid() alone left snapping inert (1.6.19 gate).
10106
+ if (typeof app.setUnitGridEnabled === 'function') app.setUnitGridEnabled(${enabled});
10107
+ else app.measurementSystem[${enabled} ? 'showGrid' : 'hideGrid']();
9465
10108
  return { success: true, action: 'set_grid', enabled: ${enabled} };
9466
10109
  })();`.trim();
9467
10110
  }
@@ -9480,17 +10123,18 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9480
10123
  })();`.trim();
9481
10124
  }
9482
10125
  case 'set_snap': {
9483
- // `enabled` is deliberately unread: there is no mode to set either way.
10126
+ // The engine has setSnapToUnitEnabled now; this refused, saying it had
10127
+ // never existed (1.6.19 gate). It is an EDITOR setting: it snaps a
10128
+ // drag or resize to the unit grid's ticks, and does nothing while that
10129
+ // grid is off. Tool-placed items are not snapped, and the result says so.
9484
10130
  return `
9485
- // Snap to grid — refused, see below
10131
+ // Snap to unit grid (drag / resize in the studio)
9486
10132
  (function() {
9487
- ${guard}
9488
- // NO EQUIVALENT. MeasurementSystem publishes snapCoordinate(), which snaps a
9489
- // coordinate you hand it, and nothing that turns snapping on as a mode —
9490
- // setSnapToUnitEnabled has never existed, so this reported success over a
9491
- // setting that was never changed. Refused by name, with the thing that works.
9492
- return { success: false, action: 'set_snap',
9493
- error: 'this build has no snap-to-unit MODE to switch: the measurement system snaps a coordinate on request rather than holding a setting. Snap positions yourself before creating or moving items, or set a grid with the grid action and place on its multiples.' };
10133
+ if (typeof app.setSnapToUnitEnabled !== 'function') { return { success: false, action: 'set_snap', error: 'this studio has no snap-to-unit setting; update FxTool' }; }
10134
+ const enabled = app.setSnapToUnitEnabled(${input.enabled !== false});
10135
+ const gridOn = typeof app.getUnitGridEnabled === 'function' ? app.getUnitGridEnabled() : null;
10136
+ return { success: true, action: 'set_snap', enabled: enabled, unitGrid: gridOn,
10137
+ note: 'snaps dragging and resizing in the studio to the unit grid' + (gridOn === false ? ', which is OFF, so nothing snaps until it is on (set_grid)' : '') + '. Items placed by tools are not snapped: give them grid multiples.' };
9494
10138
  })();`.trim();
9495
10139
  }
9496
10140
  default:
@@ -9568,6 +10212,30 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9568
10212
  * Extract the best-matching detected region from an image as a NEW item (FxTool
9569
10213
  * ImageWorkflow.extractObject). Async (runs detection first).
9570
10214
  */
10215
+ /**
10216
+ * pinepaper_styled_scene (FxTool K2). app.styledScene answers {ok:false,
10217
+ * error} rather than rejecting, so the verdict is read, not assumed. The
10218
+ * result carries the duration to export for: the scene item lives that long,
10219
+ * and an export left at its own default would cut it short or pad it.
10220
+ */
10221
+ generateStyledScene(input) {
10222
+ const opts = {
10223
+ ...(input.style !== undefined ? { style: input.style } : {}),
10224
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
10225
+ ...(input.id !== undefined ? { id: input.id } : {}),
10226
+ };
10227
+ return `
10228
+ // Styled scene${input.style ? ` (${input.style.replace(/[\r\n]/g, ' ')})` : ''}
10229
+ (async function() {
10230
+ if (typeof app.styledScene !== 'function') { return { success: false, error: 'app.styledScene unavailable — update FxTool' }; }
10231
+ const r = await app.styledScene(${JSON.stringify(input.spec ?? {})}, ${JSON.stringify(opts)});
10232
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the styled scene was refused' }; }
10233
+ const notes = ['export for ' + r.duration + ' s (agent_export duration: ' + r.duration + '): the scene runs that long'];
10234
+ if (r.style === 'dither' || r.style === 'ascii') notes.push(r.style + ' needs a Chrome renderer (WebKit ignores the canvas filter it uses)');
10235
+ notes.push('the scene redraws every exported frame; after a page reload it is a still until styled_scene is called again');
10236
+ return { success: true, itemId: r.id, style: r.style, width: r.width, height: r.height, duration: r.duration, firstFrameMs: r.firstFrameMs, notes: notes };
10237
+ })();`.trim();
10238
+ }
9571
10239
  generateExtractObject(input) {
9572
10240
  const args = JSON.stringify({
9573
10241
  ...(input.label !== undefined ? { label: input.label } : {}),
@@ -9584,6 +10252,16 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9584
10252
  }
9585
10253
  try {
9586
10254
  const res = await app.extractObject(${args});
10255
+ // A numeric itemId is Paper's own id: the engine's register fallback did
10256
+ // not take, so the new item was unaddressable and uncounted (gate run 2:
10257
+ // itemId 226, itemsCreated 0). Register it here and hand back that id.
10258
+ if (res && res.ok && typeof res.itemId === 'number' && typeof paper !== 'undefined' && paper.project) {
10259
+ const __n = res.itemId;
10260
+ const __it = paper.project.getItem({ match: function (i) { return i.id === __n; } });
10261
+ const __rid = __it && app.itemRegistry && app.itemRegistry.register ? app.itemRegistry.register(__it, 'image', { source: 'extract' }, 'import') : null;
10262
+ if (typeof __rid === 'string') res.itemId = __rid;
10263
+ else return { success: false, error: 'the object was extracted but could not be registered, so it has no usable id (Paper id ' + __n + ')' };
10264
+ }
9587
10265
  const out = Object.assign({ success: !!(res && res.ok) }, res || {});
9588
10266
  if (!out.success && typeof out.error === 'string') out.error = (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(out.error);
9589
10267
  return out;
@@ -9971,12 +10649,26 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
9971
10649
  // which get the box loop below and a note saying so.
9972
10650
  const doc = ${docJson};
9973
10651
  if (typeof app.instantiateOntology === 'function') {
9974
- const r = await app.instantiateOntology(doc, ${engineOptsJson});
10652
+ // THE LIVE CANVAS, unless one was given: the engine lays out for 1080x1080
10653
+ // otherwise, which put items at (540,540) on a 640x360 canvas (gate run 3).
10654
+ const __opts = ${engineOptsJson};
10655
+ if (!__opts.canvas && app.getCanvasSize) { const __c = app.getCanvasSize(); if (__c && __c.width > 0) __opts.canvas = { width: __c.width, height: __c.height }; }
10656
+ // REGISTRY IDS. The engine returns the DOCUMENT's ids ("bar"), which no
10657
+ // other tool resolves; the new registry entries are found by diffing.
10658
+ const __all = function () { return (app.itemRegistry && app.itemRegistry.getAll) ? app.itemRegistry.getAll() : []; };
10659
+ const __before = new Set(__all().map(function (e) { return e.itemId; }));
10660
+ const r = await app.instantiateOntology(doc, __opts);
9975
10661
  const diags = r.diagnostics || [];
10662
+ const __new = __all().filter(function (e) { return !__before.has(e.itemId) && e.item && e.item.data && e.item.data._origin === 'ontology'; }).map(function (e) { return e.itemId; });
10663
+ const docIds = r.itemIds || [];
10664
+ const idMap = {};
10665
+ if (__new.length === docIds.length) docIds.forEach(function (d, i) { idMap[d] = __new[i]; });
9976
10666
  return {
9977
10667
  success: !diags.some(function(d) { return d && d.level === 'error'; }) && !r.imagesFailed,
9978
10668
  engine: 'studio',
9979
- itemIds: r.itemIds, itemCount: r.itemIds.length,
10669
+ itemIds: __new, itemCount: __new.length, docIds: docIds,
10670
+ ...(Object.keys(idMap).length ? { idMap: idMap } : { note: 'document ids could not be paired with registry ids one to one; use itemIds' }),
10671
+ canvas: __opts.canvas || null,
9980
10672
  // Every count the engine returns, not a chosen three. imagesFailed is the
9981
10673
  // one that matters most: a raster that never decoded leaves a scene that
9982
10674
  // looks built and is not, and dropping the count is how that becomes
@@ -10464,7 +11156,9 @@ ${guard}
10464
11156
  // item — so reading .data.id off the answer returned no id, every time. The
10465
11157
  // group is registered only on one of its two branches; the other is
10466
11158
  // registered here so the caller always gets an addressable id.
10467
- const r = app.createCursiveText(${S(input.text)}, ${S(input.cursiveOptions ?? {})});
11159
+ // nib goes to the engine as a boolean or a tuning object. A STRING is
11160
+ // truthy there, so 'monoline' would have selected the broad nib (D31).
11161
+ const r = app.createCursiveText(${S(input.text)}, ${S(cursiveEngineOptions(input.cursiveOptions))});
10468
11162
  const g = r && (r.group || (r.data ? r : null));
10469
11163
  if (!g) { return { success: false, error: 'the cursive text produced no path' }; }
10470
11164
  let itemId = g.data && (g.data.id || g.data.registryId);
@@ -10505,7 +11199,11 @@ ${guard}
10505
11199
  // would otherwise read as "the conversion did nothing to your text".
10506
11200
  const r = app.convertTextToCollage(item, ${S(input.text ?? null)} || item.content, ${S(input.collageOptions ?? {})});
10507
11201
  if (!r) { return { success: false, error: 'the collage system is unavailable in this build, so the text was left as it was' }; }
10508
- return { success: true, collage: r };
11202
+ // A summary, not the collage's Paper group (that was a raw dump, gate run 3).
11203
+ ${REG_ID_JS}
11204
+ const __g = (r && (r.group || r.item)) || (r && typeof r.className === 'string' ? r : null);
11205
+ return { success: true, itemId: __g ? __ppRegId(__g) : null, letters: __g && __g.children ? __g.children.length : undefined,
11206
+ bounds: __g && __g.bounds ? { x: __g.bounds.x, y: __g.bounds.y, width: __g.bounds.width, height: __g.bounds.height } : undefined };
10509
11207
  })();`.trim();
10510
11208
  }
10511
11209
  }
@@ -10520,6 +11218,404 @@ ${guard}
10520
11218
  * only ids/count/duration are forwarded. `ids` is the whole handle set the
10521
11219
  * caller now has, since the source item is gone.
10522
11220
  */
11221
+ /**
11222
+ * pinepaper_relight — FxTool D5's relight post pass over the 3D world layer.
11223
+ * The rig is not saved with the project, GIF / APNG are not relit, and it
11224
+ * costs ~0.42 s per 1080p frame on the CPU, so a set says what an export
11225
+ * will pay.
11226
+ */
11227
+ /**
11228
+ * pinepaper_choreograph (D67). The compiler runs IN THE STUDIO, serialised
11229
+ * by toString, because the track depends on the actor's live size, scale,
11230
+ * rotation and the export frame. Merged into the actor's track, read back
11231
+ * the way keyframe_animate reads its own.
11232
+ */
11233
+ generateChoreograph(input) {
11234
+ const S = (v) => JSON.stringify(v);
11235
+ const id = S(input.itemId);
11236
+ return `
11237
+ // Choreograph ${input.beats.length} beat(s) for ${input.itemId}
11238
+ (function() {
11239
+ const __it = (app.getItemById && app.getItemById(${id}))
11240
+ || (app.itemRegistry && app.itemRegistry.get && (app.itemRegistry.get(${id}) || {}).item);
11241
+ if (!__it || !__it.bounds) { return { success: false, error: ${id} + ' is not on the canvas — check the id with pinepaper_get_items.' }; }
11242
+ const __sc = __it.scaling || { x: 1, y: 1 };
11243
+ const __b = __it.bounds;
11244
+ const __baked = (__it.data && typeof __it.data._bakedRotation === 'number') ? __it.data._bakedRotation : 0;
11245
+ const __fr = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11246
+ const __frame = { x: __fr.x || 0, y: __fr.y || 0, width: __fr.width, height: __fr.height };
11247
+ const __actor = { x: __it.position.x, y: __it.position.y,
11248
+ w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
11249
+ sx: __sc.x || 1, sy: __sc.y || 1, rot: (__it.rotation || 0) + __baked };
11250
+ const __r = (${compileChoreography.toString()})(__actor, __frame, ${S(input.beats)});
11251
+ if (__r.error) { return { success: false, itemId: ${id}, error: __r.error }; }
11252
+ const __prev = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes : [];
11253
+ const __prevTimes = __prev.map(function (k) { return k && k.time; });
11254
+ const __ours = ['x', 'y', 'scaleX', 'scaleY', 'rotation', 'opacity'];
11255
+ const __other = __prev.reduce(function (acc, k) { Object.keys((k && k.properties) || {}).forEach(function (p) { if (__ours.indexOf(p) < 0 && acc.indexOf(p) < 0) acc.push(p); }); return acc; }, []);
11256
+ app.addAnimation(${id}, __r.keyframes, { duration: Math.max(__r.end, __prev.reduce(function (m, k) { return Math.max(m, (k && k.time) || 0); }, 0)), loop: false, mode: 'merge' });
11257
+ const __now = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes.map(function (k) { return k && k.time; }) : [];
11258
+ const __warnings = (__r.warnings || []).slice();
11259
+ if (__prevTimes.length && !__prevTimes.every(function (t) { return __now.indexOf(t) >= 0; })) {
11260
+ __warnings.push('this studio replaced the existing track of the actor (' + __prevTimes.length + ' key(s)) instead of merging: put the other keys in after choreographing.');
11261
+ }
11262
+ if (__other.length) {
11263
+ __warnings.push('the actor already animates ' + __other.join(', ') + '; the beat keys leave those out, which the engine can break at the beat keys. Re-key ' + __other.join(', ') + ' with every key carrying them, after choreographing.');
11264
+ }
11265
+ if (__r.final && !__r.final.onScreen) __warnings.push('after the last beat the actor is off screen at [' + __r.final.x + ', ' + __r.final.y + '].');
11266
+ const __res = { success: true, itemId: ${id}, beats: ${input.beats.length}, keyframes: __r.keyframes.length, duration: __r.end, final: __r.final };
11267
+ ${input.sound ? ` // SOUND ON THE ACTION (D68): one placed sound per moment the motion makes —
11268
+ // pop, launch, contact, shake — loud as the moment is strong. A sound that
11269
+ // does not place is reported; it never undoes the motion.
11270
+ const __map = Object.assign({ pop: 'pop', whoosh: 'whoosh', contact: 'tom', shake: 'hat' }, ${S(typeof input.sound === 'object' ? Object.fromEntries(Object.entries(input.sound).filter(([k]) => k !== 'gain')) : {})});
11271
+ const __gain = ${S(typeof input.sound === 'object' && input.sound.gain !== undefined ? input.sound.gain : 0.8)};
11272
+ const __sounds = [];
11273
+ const __silent = [];
11274
+ if (typeof app.createSound !== 'function' || (typeof app.sfxSpec !== 'function' && typeof app.percussionSpec !== 'function')) {
11275
+ __warnings.push('no sound was placed: this studio cannot place synthesized sounds (app.createSound / sfxSpec). The motion is written.');
11276
+ } else {
11277
+ (__r.events || []).forEach(function (e) {
11278
+ const name = __map[e.kind];
11279
+ if (name === false || name === undefined) return;
11280
+ const base = (typeof app.sfxSpec === 'function' && app.sfxSpec(name)) || (typeof app.percussionSpec === 'function' && app.percussionSpec(name));
11281
+ if (!base) { if (__silent.indexOf(name) < 0) __silent.push(name); return; }
11282
+ const spec = Object.assign({}, base, { gain: Math.round(((base.gain || 0.8) * __gain * (0.35 + 0.65 * e.strength)) * 1000) / 1000 });
11283
+ // MP4 (AAC) encoder priming swallows the first ~50 ms of audio, and this
11284
+ // muxer writes no edit list (the engine reports audio_lead_trimmed): a pop
11285
+ // at 0 s exported as silence (prod retest of D68). 60 ms later is in sync
11286
+ // to the eye and audible in every format.
11287
+ const at = e.time < 0.06 ? 0.06 : e.time;
11288
+ const it = app.createSound(spec, { startTime: at, duration: spec.duration || 0.5 });
11289
+ if (it && it.data) __sounds.push(Object.assign({ t: at, kind: e.kind, sound: name, itemId: it.data.id }, at !== e.time ? { movedFrom: e.time } : {}));
11290
+ });
11291
+ if (__silent.length) __warnings.push('not in the sound catalogue, so silent: ' + __silent.join(', ') + ' (pinepaper_sound list_sfx / list_percussion for the names).');
11292
+ }
11293
+ __res.sounds = __sounds;
11294
+ ` : ''}
11295
+ if (__warnings.length) __res.warnings = __warnings;
11296
+ return __res;
11297
+ })();`.trim();
11298
+ }
11299
+ /**
11300
+ * pinepaper_morph_sequence (D73): ONE element through N states — R8's pill →
11301
+ * input → dot → grid → composer → orb. The engine already chains morphs_to
11302
+ * on one shape (per-relation state since gap 2.27); what a model could not
11303
+ * do was time the chain, make the reference shapes, and ride the camera on
11304
+ * the same curve. Compiled here to that chain, a camera track and sounds.
11305
+ */
11306
+ generateMorphSequence(input) {
11307
+ const S = (v) => JSON.stringify(v);
11308
+ const states = [...input.states].sort((a, b) => a.at - b.at).map((st) => ({ at: st.at, duration: st.duration ?? 0.8, to: st.to ?? null, shape: st.shape ?? null }));
11309
+ const cam = input.camera === true ? { fill: 0.45 } : (input.camera || null);
11310
+ const sound = input.sound === true ? 'whoosh' : (input.sound || null);
11311
+ return `
11312
+ // Morph sequence: ${states.length} state(s)
11313
+ (function() {
11314
+ const src = app.getItemById && app.getItemById(${S(input.itemId)});
11315
+ if (!src) { return { success: false, error: ${S(input.itemId)} + ' is not on the canvas.' }; }
11316
+ if (typeof app.addRelation !== 'function') { return { success: false, error: 'this studio cannot add relations.' }; }
11317
+ const states = ${S(states)};
11318
+ const done = [];
11319
+ for (let i = 0; i < states.length; i++) {
11320
+ const st = states[i];
11321
+ let tid = st.to;
11322
+ if (!tid) {
11323
+ const p = Object.assign({}, st.shape.properties || {}, { x: st.shape.position.x, y: st.shape.position.y });
11324
+ const made = app.create(st.shape.itemType, p);
11325
+ tid = made && made.data && made.data.registryId;
11326
+ if (!tid) { return { success: false, error: 'state ' + i + ': could not make the ' + st.shape.itemType + ' reference shape', states: done }; }
11327
+ } else if (!(app.getItemById && app.getItemById(tid))) {
11328
+ return { success: false, error: 'state ' + i + ': ' + tid + ' is not on the canvas', states: done };
11329
+ }
11330
+ const ok = app.addRelation(${S(input.itemId)}, tid, 'morphs_to', { duration: st.duration, delay: st.at, easing: ${S(input.easing ?? 'easeInOut')}, hideTarget: true });
11331
+ if (ok === false) { return { success: false, error: 'state ' + i + ': the studio refused the morph into ' + tid, states: done }; }
11332
+ done.push({ at: st.at, duration: st.duration, targetId: tid });
11333
+ }
11334
+ const res = { success: true, itemId: ${S(input.itemId)}, states: done, end: done[done.length - 1].at + done[done.length - 1].duration };
11335
+ const n = app.getRelations ? app.getRelations(${S(input.itemId)}, 'morphs_to').length : null;
11336
+ if (n !== null && n < done.length) res.warning = 'only ' + n + ' of ' + done.length + ' morphs are on the element.';
11337
+ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph lands.
11338
+ {
11339
+ const fr = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11340
+ const fill = ${S(cam.fill ?? 0.45)};
11341
+ const frameOf = function (it) { const b = it.bounds; return { focus: [b.center.x, b.center.y], zoom: Math.max(0.2, Math.min(8, fill * Math.min(fr.width / Math.max(1, b.width), fr.height / Math.max(1, b.height)))) }; };
11342
+ const f0 = frameOf(src);
11343
+ const kfs = [{ time: 0, zoom: f0.zoom, focus: f0.focus }];
11344
+ done.forEach(function (d) {
11345
+ const t = app.getItemById(d.targetId);
11346
+ const f = frameOf(t);
11347
+ const prev = kfs[kfs.length - 1];
11348
+ kfs.push({ time: d.at, zoom: prev.zoom, focus: prev.focus });
11349
+ kfs.push({ time: d.at + d.duration, zoom: f.zoom, focus: f.focus, easing: ${S(cam.easing ?? 'easeInOut')} });
11350
+ });
11351
+ // The SAME door pinepaper_camera_animate uses: app.camera.animate where the
11352
+ // build has it, else the camera_animates relation — prod has only the
11353
+ // relation, and probing app.camera.animate alone reported "no camera" there.
11354
+ const r = app.camera && app.camera.animate
11355
+ ? app.camera.animate(kfs, res.end, false, 0, {})
11356
+ : app.addRelation('camera', 'camera', 'camera_animates', { keyframes: kfs, duration: res.end, loop: false, delay: 0 });
11357
+ // Read the track back where the engine keeps it.
11358
+ const rr = app.relationRegistry;
11359
+ const written = rr && rr.getCameraAnimationParams ? rr.getCameraAnimationParams() : null;
11360
+ if (r === false || (rr && rr.getCameraAnimationParams && !written)) {
11361
+ res.warning = (res.warning ? res.warning + ' ' : '') + 'the camera track was not written; the morphs run without it.';
11362
+ } else {
11363
+ res.camera = { keyframes: kfs.length };
11364
+ }
11365
+ }
11366
+ ` : ''}${sound ? ` // A sound on each morph (D68's placement, with its MP4-lead nudge).
11367
+ const base = (app.sfxSpec && app.sfxSpec(${S(sound)})) || (app.percussionSpec && app.percussionSpec(${S(sound)}));
11368
+ if (base && app.createSound) {
11369
+ res.sounds = done.map(function (d) { const at = d.at < 0.06 ? 0.06 : d.at; const it = app.createSound(Object.assign({}, base), { startTime: at, duration: base.duration || 0.5 }); return { t: at, itemId: it && it.data && it.data.id }; });
11370
+ } else { res.warning = (res.warning ? res.warning + ' ' : '') + ${S(`no sound placed: ${sound} is not in the catalogue, or this studio cannot place sounds.`)}; }
11371
+ ` : ''} return res;
11372
+ })();`.trim();
11373
+ }
11374
+ /**
11375
+ * pinepaper_original_character (D66): app.createCharacter and its schema
11376
+ * helpers. The helpers read the SAME table the validator uses, so a model
11377
+ * reads the legal values (describe) before it guesses; a wrong one is
11378
+ * refused by name and nothing is drawn.
11379
+ */
11380
+ generateOriginalCharacter(input) {
11381
+ const S = (v) => JSON.stringify(v);
11382
+ const guard = `if (typeof app.createCharacter !== 'function') { return { success: false, error: 'this studio has no original characters (app.createCharacter) — it predates FxTool D66.' }; }`;
11383
+ switch (input.action) {
11384
+ case 'bases':
11385
+ return `(function() {\n ${guard}\n return { success: true, action: 'bases', bases: app.listCharacterBases() };\n})();`;
11386
+ case 'describe':
11387
+ return `(function() {\n ${guard}\n return { success: true, action: 'describe', schema: app.describeCharacterVariantSchema(${input.base ? S(input.base) : ''}) };\n})();`;
11388
+ case 'random':
11389
+ return `(function() {\n ${guard}\n const r = app.randomCharacterVariant(${S(input.base)}, ${S(input.seed)}${input.fixed ? `, { fixed: ${S(input.fixed)} }` : ''});\n if (!r || r.ok === false) return { success: false, action: 'random', error: (r && r.errors && r.errors.join('; ')) || 'no variant' };\n return { success: true, action: 'random', base: ${S(input.base)}, seed: ${S(input.seed)}, variant: r.variant };\n})();`;
11390
+ default: {
11391
+ const spec = { base: input.base, ...(input.variant ? { variant: input.variant } : {}), ...(input.at ? { x: input.at.x, y: input.at.y } : {}),
11392
+ ...(input.height !== undefined ? { height: input.height } : {}), ...(input.scale !== undefined ? { scale: input.scale } : {}), ...(input.rig !== undefined ? { rig: input.rig } : {}) };
11393
+ return `
11394
+ // Original character: create
11395
+ (async function() {
11396
+ ${guard}
11397
+ const r = await app.createCharacter(${S(spec)});
11398
+ if (!r || r.ok === false) return { success: false, action: 'create', errors: (r && r.errors) || [], error: (r && r.errors && r.errors.join('; ')) || 'the character was refused' };
11399
+ return { success: true, action: 'create', itemId: r.id, rigId: r.rigId, parts: r.parts, name: r.name, base: r.base, look: r.look, variant: r.variant };
11400
+ })();`.trim();
11401
+ }
11402
+ }
11403
+ }
11404
+ /**
11405
+ * pinepaper_look (D64): one look over an item, a subtree or the whole scene
11406
+ * — ids, keys and relations kept, removable pixel for pixel. Every apply
11407
+ * carries the look's own FIDELITY grade, and anything below 'convincing'
11408
+ * says so in the reply, so a weak approximation is never handed over as
11409
+ * the finished style.
11410
+ */
11411
+ generateLook(input) {
11412
+ const S = (v) => JSON.stringify(v);
11413
+ const guard = `if (typeof app.applyLook !== 'function') { return { success: false, error: 'this studio has no looks (app.applyLook) — it predates FxTool D64.' }; }`;
11414
+ const target = S(input.target ?? 'scene');
11415
+ switch (input.action) {
11416
+ case 'list':
11417
+ return `(function() {\n ${guard}\n return { success: true, action: 'list', looks: app.listLooks(), note: 'fidelity is the engine grade: native (the medium itself), faithful (close, approximations named), impression (an evocation, not the real style). Describe one before promising it.' };\n})();`;
11418
+ case 'describe':
11419
+ return `(function() {\n ${guard}\n const r = app.describeLook(${S(input.look)});\n if (!r || r.ok === false) return { success: false, action: 'describe', error: (r && r.error) || 'unknown look', known: r && r.known };\n return { success: true, action: 'describe', fidelity: r.look && r.look.fidelity, approximations: (r.look && r.look.approximations) || [], recipe: r.look, enginePaths: r.enginePaths, sceneOnly: r.sceneOnly };\n})();`;
11420
+ case 'get':
11421
+ return `(function() {\n ${guard}\n return { success: true, action: 'get', target: ${target}, look: app.getLook(${target}) || null };\n})();`;
11422
+ case 'remove':
11423
+ return `
11424
+ // Look: remove (restores what was there)
11425
+ (async function() {
11426
+ ${guard}
11427
+ const r = await app.removeLook(${target});
11428
+ if (!r || r.ok === false) return { success: false, action: 'remove', error: (r && r.error) || 'nothing to remove' };
11429
+ return { success: true, action: 'remove', target: ${target}, removed: r.removed, scene: r.scene };
11430
+ })();`.trim();
11431
+ case 'grid':
11432
+ return `
11433
+ // Look: a contact sheet of looks over the current scene
11434
+ (async function() {
11435
+ ${guard}
11436
+ const r = await app.renderLookGrid(${input.looks ? S(input.looks) : 'undefined'}, ${S({ ...(input.cols ? { cols: input.cols } : {}), ...(input.cellWidth ? { cellWidth: input.cellWidth } : {}), ...(input.t !== undefined ? { t: input.t } : {}) })});
11437
+ if (!r || r.ok === false || !r.dataURL) return { success: false, action: 'grid', error: (r && r.error) || 'the grid did not render' };
11438
+ return { success: true, action: 'grid', data: r.dataURL, mimeType: 'image/png', cells: (r.cells || []).map(function (c) { return { id: c.id, name: c.name, fidelity: c.fidelity }; }), cols: r.cols, rows: r.rows };
11439
+ })();`.trim();
11440
+ default: {
11441
+ const opts = input.scope ? { scope: input.scope } : {};
11442
+ return `
11443
+ // Look: apply
11444
+ (async function() {
11445
+ ${guard}
11446
+ const d = app.describeLook(${S(input.look)});
11447
+ const r = await app.applyLook(${target}, ${S(input.look)}, ${S(opts)});
11448
+ if (!r || r.ok === false) return { success: false, action: 'apply', error: (r && r.error) || 'the look was refused', known: r && r.known };
11449
+ const fidelity = (d && d.look && d.look.fidelity) || (r.look && r.look.fidelity) || null;
11450
+ const res = { success: true, action: 'apply', target: ${target}, look: r.look && (r.look.id || r.look.name), fidelity: fidelity,
11451
+ restyled: r.restyled, texts: r.texts, images: r.images, brushed: r.brushed, keyframes: r.keyframes, filters: r.filters, ids: r.ids,
11452
+ approximations: r.approximations || [], warnings: r.warnings || [] };
11453
+ // The engine's grades: native (the medium itself), faithful (close, with
11454
+ // named approximations), impression (an evocation), custom (an ad-hoc spec).
11455
+ const __approx = (res.approximations && res.approximations.length) ? res.approximations.join('; ') : '';
11456
+ if (fidelity === 'impression') {
11457
+ res.warning = 'this look is an IMPRESSION of the style (the engine\\'s grade)' + (__approx ? ' — it approximates: ' + __approx : '') + '. Present it as an approximation of the style, not the finished style.';
11458
+ } else if (fidelity === 'faithful' && __approx) {
11459
+ res.note = 'a faithful look; what it approximates: ' + __approx + '.';
11460
+ } else if (fidelity === 'custom') {
11461
+ res.note = 'a custom look spec: the engine has no fidelity grade for it.';
11462
+ }
11463
+ return res;
11464
+ })();`.trim();
11465
+ }
11466
+ }
11467
+ }
11468
+ /** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
11469
+ generateEmitter(input) {
11470
+ const S = (v) => JSON.stringify(v);
11471
+ if (input.action === 'set') {
11472
+ return `
11473
+ // Emitter: change a spec
11474
+ (function() {
11475
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
11476
+ const r = app.setEmitter(${S(input.itemId)}, ${S(input.spec)});
11477
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the emitter change was refused' }; }
11478
+ return { success: true, action: 'set', itemId: ${S(input.itemId)}, spec: r.spec };
11479
+ })();`.trim();
11480
+ }
11481
+ return `
11482
+ // Emitter: create
11483
+ (function() {
11484
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
11485
+ const it = app.create('emitter', ${S(input.spec)});
11486
+ // A bad spec comes back null with an emitter-invalid warning; the reason
11487
+ // reaches this reply through the governor's warnings.
11488
+ if (!it) { return { success: false, error: 'the emitter spec was refused' }; }
11489
+ const id = it.data && (it.data.registryId || it.data.id);
11490
+ if (!id) { return { success: false, error: 'the emitter was made but not registered — no usable id' }; }
11491
+ return { success: true, action: 'create', itemId: id };
11492
+ })();`.trim();
11493
+ }
11494
+ /** pinepaper_render_hook (D29): drawing saved as source, re-installed on load. */
11495
+ generateRenderHook(input) {
11496
+ const S = (v) => JSON.stringify(v);
11497
+ const guard = `if (typeof app.registerRenderHook !== 'function') { return { success: false, error: 'this studio has no saved render hooks (app.registerRenderHook) — it predates FxTool D29.' }; }`;
11498
+ if (input.action === 'list') {
11499
+ return `(function() {\n ${guard}\n return { success: true, action: 'list', hooks: app.listRenderHooks() };\n})();`;
11500
+ }
11501
+ if (input.action === 'unregister') {
11502
+ return `(function() {\n ${guard}\n const had = app.unregisterRenderHook(${S(input.id)});\n if (!had) return { success: false, error: 'no render hook ' + ${S(input.id)} };\n return { success: true, action: 'unregister', id: ${S(input.id)} };\n})();`;
11503
+ }
11504
+ const spec = { id: input.id, source: input.source, ...(input.layer ? { layer: input.layer } : {}), ...(input.deterministic !== undefined ? { deterministic: input.deterministic } : {}), ...(input.seed !== undefined ? { seed: input.seed } : {}) };
11505
+ return `
11506
+ // Render hook: register (saved as source)
11507
+ (function() {
11508
+ ${guard}
11509
+ const r = app.registerRenderHook(${S(spec)});
11510
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the render hook was refused' }; }
11511
+ return { success: true, action: 'register', id: r.id, layer: r.layer, deterministic: r.deterministic, seed: r.seed };
11512
+ })();`.trim();
11513
+ }
11514
+ generateRelight(input) {
11515
+ const S = (v) => JSON.stringify(v);
11516
+ const missing = (fn) => `if (typeof app.${fn} !== 'function') { return { success: false, action: ${S(input.action)}, error: 'this studio has no relight pass (app.${fn}) — it predates FxTool D5.' }; }`;
11517
+ switch (input.action) {
11518
+ case 'set': {
11519
+ const spec = S({
11520
+ ...(input.lights !== undefined ? { lights: input.lights } : {}),
11521
+ ...(input.ambient !== undefined ? { ambient: input.ambient } : {}),
11522
+ ...(input.strength !== undefined ? { strength: input.strength } : {}),
11523
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
11524
+ ...(input.preview !== undefined ? { preview: input.preview } : {}),
11525
+ });
11526
+ return `
11527
+ // Relight: set the rig
11528
+ (function() {
11529
+ ${missing('setRelight')}
11530
+ const r = app.setRelight(${spec});
11531
+ if (!r || r.ok === false) { return { success: false, action: 'set', error: (r && ((r.errors && r.errors.join('; ')) || r.reason)) || 'the relight rig was refused' }; }
11532
+ // What an export will pay: ~418 ms per 1920x1080 frame, scaled by pixels.
11533
+ const __f = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11534
+ const __ms = Math.round(418 * (__f.width * __f.height) / (1920 * 1080));
11535
+ const res = { success: true, action: 'set', active: r.active, relight: r.relight,
11536
+ cost: { msPerFrame: __ms, frame: { width: __f.width, height: __f.height },
11537
+ tenSecondsAt30fps: Math.round(__ms * 300 / 1000) + ' s extra' },
11538
+ note: 'applied in the preview and in PNG, PDF, MP4 and WebM exports; GIF and APNG are not relit. The rig is NOT saved with the project — set it again after a reload.' };
11539
+ if (r.reason) res.reason = r.reason;
11540
+ if (r.active === false) res.warning = 'the rig is set but not active' + (r.reason ? ': ' + r.reason : '') + ' — relight needs a 3D world (pinepaper_world3d create).';
11541
+ return res;
11542
+ })();`.trim();
11543
+ }
11544
+ case 'get':
11545
+ return `
11546
+ // Relight: the current rig
11547
+ (function() {
11548
+ ${missing('getRelight')}
11549
+ return Object.assign({ success: true, action: 'get' }, app.getRelight());
11550
+ })();`.trim();
11551
+ case 'clear':
11552
+ return `
11553
+ // Relight: remove the rig
11554
+ (function() {
11555
+ ${missing('clearRelight')}
11556
+ const r = app.clearRelight();
11557
+ return { success: true, action: 'clear', cleared: !!(r && r.cleared) };
11558
+ })();`.trim();
11559
+ case 'list_lights':
11560
+ return `
11561
+ // Relight: light types
11562
+ (function() {
11563
+ ${missing('listRelightLights')}
11564
+ return { success: true, action: 'list_lights', lights: app.listRelightLights() };
11565
+ })();`.trim();
11566
+ }
11567
+ }
11568
+ /**
11569
+ * pinepaper_shader_graph — FxTool D5's JSON pixel graphs. Applying to an
11570
+ * item makes a NEW image item and hides the source (one undo step);
11571
+ * applying to 'scene' returns the processed frame and changes nothing.
11572
+ */
11573
+ generateShaderGraph(input) {
11574
+ const S = (v) => JSON.stringify(v);
11575
+ const ref = input.graph ?? input.graphId;
11576
+ const head = ` if (!app.shaderGraph || typeof app.shaderGraph.create !== 'function') { return { success: false, action: ${S(input.action)}, error: 'this studio has no shader graph (app.shaderGraph) — it predates FxTool D5.' }; }`;
11577
+ const fail = `(r && ((r.errors && r.errors.join('; ')) || r.reason)) || 'the shader graph call failed'`;
11578
+ const body = (() => {
11579
+ switch (input.action) {
11580
+ case 'node_types': return ` return { success: true, action: 'node_types', nodeTypes: app.shaderGraph.nodeTypes() };`;
11581
+ case 'list': return ` const r = app.shaderGraph.list();\n return { success: true, action: 'list', graphs: (r && r.graphs) || [] };`;
11582
+ case 'validate': return ` const r = app.shaderGraph.validate(${S(input.graph)});\n if (!r || !r.ok) return { success: false, action: 'validate', errors: (r && r.errors) || [], error: ${fail} };\n return { success: true, action: 'validate', order: r.order };`;
11583
+ case 'create': return ` const r = app.shaderGraph.create(${S(input.graph)});\n if (!r || !r.ok) return { success: false, action: 'create', errors: (r && r.errors) || [], error: ${fail} };\n return { success: true, action: 'create', graphId: r.id, order: r.order, nodes: r.nodes, replaced: r.replaced };`;
11584
+ case 'get': return ` const r = app.shaderGraph.get(${S(input.graphId)});\n if (!r || !r.ok) return { success: false, action: 'get', error: ${fail} };\n return { success: true, action: 'get', graph: r.graph };`;
11585
+ case 'remove': return ` const r = app.shaderGraph.remove(${S(input.graphId)});\n if (!r || !r.ok) return { success: false, action: 'remove', error: ${fail} };\n return { success: true, action: 'remove', removed: r.removed };`;
11586
+ case 'apply': {
11587
+ const opts = S({ ...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}), ...(input.asItem !== undefined ? { asItem: input.asItem } : {}) });
11588
+ return ` if (typeof app.applyShaderGraph !== 'function') { return { success: false, action: 'apply', error: 'this studio has no app.applyShaderGraph — it predates FxTool D5.' }; }
11589
+ const r = await app.applyShaderGraph(${S(input.target)}, ${S(ref)}, ${opts});
11590
+ if (!r || !r.ok) return { success: false, action: 'apply', errors: (r && r.errors) || undefined, error: ${fail} };
11591
+ if (r.target === 'item') {
11592
+ if (!r.id) return { success: false, action: 'apply', error: 'the graph ran but made no item' };
11593
+ // The new item's OWN bounds: a bloom with reach (FxTool D60) is larger
11594
+ // than its source, centred on it, so the source's bounds are not these.
11595
+ const __ne = app.itemRegistry && typeof app.itemRegistry.get === 'function' ? app.itemRegistry.get(r.id) : null;
11596
+ const __ni = __ne && __ne.item;
11597
+ // An image item made from a data URL decodes ASYNCHRONOUSLY and has 0x0
11598
+ // bounds until it does (prod read {width:0, height:0}); wait for it.
11599
+ if (__ni && __ni.className === 'Raster' && !__ni.loaded && typeof __ni.once === 'function') {
11600
+ await new Promise(function (resolve) { const t = setTimeout(resolve, 5000); __ni.once('load', function () { clearTimeout(t); resolve(); }); });
11601
+ }
11602
+ const __nb = __ni && __ni.bounds && __ni.bounds.width > 0 ? __ni.bounds : null;
11603
+ return { success: true, action: 'apply', target: 'item', itemId: r.id, sourceId: r.sourceId, sourceHidden: r.sourceHidden, graphId: r.graphId, width: r.width, height: r.height,
11604
+ ...(__nb ? { bounds: { x: __nb.x, y: __nb.y, width: __nb.width, height: __nb.height } } : {}) };
11605
+ }
11606
+ const res = { success: true, action: 'apply', target: 'scene', graphId: r.graphId, width: r.width, height: r.height };
11607
+ if (r.id) { res.itemId = r.id; } else { res.data = r.dataURL; res.mimeType = 'image/png'; res.note = 'the processed frame; the scene is unchanged. asItem: true adds it as an image item instead.'; }
11608
+ return res;`;
11609
+ }
11610
+ }
11611
+ })();
11612
+ return `
11613
+ // Shader graph: ${input.action}
11614
+ (async function() {
11615
+ ${head}
11616
+ ${body}
11617
+ })();`.trim();
11618
+ }
10523
11619
  /**
10524
11620
  * The medium axis. `list_media` and `resolve` are READS — they exist so an
10525
11621
  * agent can find out what this build can honestly make before it promises a
@@ -10542,8 +11638,43 @@ ${guard}
10542
11638
  if (!r || r.ok === false) {
10543
11639
  return { success: false, error: (r && (r.error || r.reason)) || 'the medium could not be applied', medium: ${S(input.medium)}, ...(r && r.fidelity ? { fidelity: r.fidelity } : {}) };
10544
11640
  }
10545
- return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.note ? { note: r.note } : {}) };
11641
+ // ink / charcoal restyle IN PLACE (FxTool D32): the same item, marked with
11642
+ // data.brushStyle on its paths and texts. Read the mark back, so an item
11643
+ // the engine passed over is a failure rather than a success (gate D32).
11644
+ if (${S(input.medium)} === 'ink' || ${S(input.medium)} === 'charcoal') {
11645
+ const __e = app.itemRegistry && app.itemRegistry.get(r.id || ${S(input.itemId)});
11646
+ const __it = (__e && __e.item) || r.item;
11647
+ let __styled = 0;
11648
+ const __walk = function (n) {
11649
+ if (!n) return;
11650
+ if (n.data && n.data.brushStyle) __styled++;
11651
+ (n.children || []).forEach(__walk);
11652
+ };
11653
+ __walk(__it);
11654
+ if (!__styled) {
11655
+ return { success: false, action: 'apply', medium: ${S(input.medium)}, error: 'the studio reported ' + ${S(input.medium)} + ' applied, but no path or text in ' + ${S(input.itemId)} + ' carries it. Ink and charcoal restyle paths and text; a primitive shape is skipped.' };
11656
+ }
11657
+ return { success: true, action: 'apply', medium: ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id || ${S(input.itemId)}, inPlace: true, styled: __styled, ...(r.note ? { note: r.note } : {}) };
11658
+ }
11659
+ return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.filter ? { filter: r.filter } : {}), ...(r.note ? { note: r.note } : {}) };
10546
11660
  })();`.trim();
11661
+ case 'sdf_stroke': {
11662
+ // FxTool D5: an SDF capsule stroke rendered to a bitmap and created as a
11663
+ // registered, undoable image item. CPU-backed.
11664
+ const opts = S({
11665
+ ...(input.width !== undefined ? { width: input.width } : {}),
11666
+ ...(input.softness !== undefined ? { softness: input.softness } : {}),
11667
+ ...(input.color !== undefined ? { color: input.color } : {}),
11668
+ ...(input.opacity !== undefined ? { opacity: input.opacity } : {}),
11669
+ ...(input.resolution !== undefined ? { resolution: input.resolution } : {}),
11670
+ ...(input.name !== undefined ? { name: input.name } : {}),
11671
+ });
11672
+ return wrap('Medium: SDF brush stroke', ` if (typeof app.sdfBrushStroke !== 'function') { return { success: false, action: 'sdf_stroke', error: 'this studio has no SDF brush (app.sdfBrushStroke) — it predates FxTool D5.' }; }
11673
+ const r = app.sdfBrushStroke(${S(input.points)}, ${opts});
11674
+ if (!r || r.ok === false) { return { success: false, action: 'sdf_stroke', error: (r && ((r.errors && r.errors.join('; ')) || r.reason)) || 'the stroke was not drawn' }; }
11675
+ if (!r.id) { return { success: false, action: 'sdf_stroke', error: 'the stroke was drawn but returned no item id' }; }
11676
+ return { success: true, action: 'sdf_stroke', itemId: r.id, bounds: r.bounds, pixelSize: r.pixelSize, backend: r.backend };`);
11677
+ }
10547
11678
  case 'list_media':
10548
11679
  return wrap('Medium: list', ` if (typeof app.listDesignMedia !== 'function') { return { success: false, error: 'medium axis unavailable — update FxTool' }; }
10549
11680
  const media = app.listDesignMedia();
@@ -10792,14 +11923,60 @@ ${guard}
10792
11923
  // Import a layered character (decomposer output → role-bound parts; blink works out of the box)
10793
11924
  (async function() {
10794
11925
  if (typeof app.importLayeredCharacter !== 'function') { return { success: false, error: 'app.importLayeredCharacter unavailable — update FxTool to a layered-character build' }; }
10795
- // The importer's contract is a Map (it calls images.get) — the wire format is
10796
- // a plain object, so rebuild the Map here.
10797
- const bundle = { info: ${S(input.info)}, images: new Map(Object.entries(${S(input.images)})) };
11926
+ // The engine looks layers up by NORMALISED tag (lowercase, no dir/extension,
11927
+ // '_'/'-' collapsed to a space): parseSeeThroughManifest asks
11928
+ // images.has(normalizePartTag(tag)), then images.get(layer.tag). Its own
11929
+ // folder reader keys the Map that way; a wire object keyed 'eye_left' is never
11930
+ // found and every multi-word layer is skipped as "no image found".
11931
+ // Mirrors FxTool js/ontology/CharacterPartTaxonomy.js normalizePartTag.
11932
+ const norm = (t) => String(t).trim().toLowerCase().replace(/^.*[\\\\/]/, '').replace(/\\.(png|webp|tga|psd)$/i, '').replace(/[_\\-\\s]+/g, ' ').trim();
11933
+ // The taxonomy resolves DECOMPOSER tags (eyel, iridesr, eyebrowl, hairf) — not
11934
+ // the role tokens it emits. A layer tagged with a role token (eye_left,
11935
+ // pupil_right, hair_front) resolves to no role, so alias role tokens to the
11936
+ // decomposer tag that produces them.
11937
+ const SIDED = { eye: 'eye', pupil: 'irides', brow: 'eyebrow', eyelash: 'eyelash', ear: 'ears', hand: 'handwear' };
11938
+ const FLAT = { 'hair front': 'hairf', 'hair back': 'hairb', torso: 'topwear', hips: 'bottomwear', legs: 'legwear', feet: 'footwear', prop: 'objects' };
11939
+ const canon = (t) => {
11940
+ const k = norm(t);
11941
+ let m = /^(.+) (left|right|l|r)$/.exec(k);
11942
+ if (m && SIDED[m[1]] !== undefined) return SIDED[m[1]] + m[2][0];
11943
+ m = /^(left|right) (.+)$/.exec(k);
11944
+ if (m && SIDED[m[2]] !== undefined) return SIDED[m[2]] + m[1][0];
11945
+ return FLAT[k] || k;
11946
+ };
11947
+ const rawInfo = ${S(input.info)};
11948
+ const rawImages = ${S(input.images)};
11949
+ const partsKey = rawInfo.parts ? 'parts' : (rawInfo.tag2pinfo ? 'tag2pinfo' : null);
11950
+ const info = { ...rawInfo };
11951
+ const notes = [];
11952
+ if (partsKey && rawInfo[partsKey] && typeof rawInfo[partsKey] === 'object' && !Array.isArray(rawInfo[partsKey])) {
11953
+ info[partsKey] = {};
11954
+ for (const [t, v] of Object.entries(rawInfo[partsKey])) {
11955
+ const c = canon(t);
11956
+ if (info[partsKey][c]) { notes.push('layer "' + t + '" collides with another layer as "' + c + '" — kept the first'); continue; }
11957
+ info[partsKey][c] = v;
11958
+ }
11959
+ }
11960
+ const images = new Map();
11961
+ const suppliedBy = {}; // canonical tag → the caller's own key, for naming
11962
+ for (const [t, v] of Object.entries(rawImages)) {
11963
+ const c = canon(t);
11964
+ if (images.has(c)) { notes.push('image "' + t + '" collides with "' + suppliedBy[c] + '" — kept the first'); continue; }
11965
+ images.set(c, v); suppliedBy[c] = t;
11966
+ }
11967
+ const bundle = { info, images };
10798
11968
  const r = await app.importLayeredCharacter(bundle, ${opts});
10799
11969
  if (!r || !r.groupId) { return { success: false, error: (r && r.error) || 'layered character import failed' }; }
10800
11970
  // roles maps part roles → item ids; zero roles wired means the character
10801
11971
  // renders but will NOT animate — surface the count so that cannot hide.
10802
- return { success: true, groupId: r.groupId, parts: (r.parts || []).length, roles: r.roles || {}, rolesWired: Object.keys(r.roles || {}).length, warnings: r.warnings || [] };
11972
+ const imported = new Set((r.parts || []).map((p) => p.tag));
11973
+ const skipped = Object.keys(suppliedBy).filter((c) => !imported.has(c)).map((c) => suppliedBy[c]);
11974
+ const unroled = (r.parts || []).filter((p) => !p.role).map((p) => suppliedBy[p.tag] || p.tag);
11975
+ const result = { groupId: r.groupId, parts: (r.parts || []).length, roles: r.roles || {}, rolesWired: Object.keys(r.roles || {}).length, skipped, unroled, warnings: [...notes, ...(r.warnings || [])] };
11976
+ // A supplied image that did not land is the caller's input dropped — not a
11977
+ // success. The group IS on the canvas, so hand back its id to fix or delete.
11978
+ if (skipped.length) { return { success: false, ...result, error: 'supplied layer(s) not imported: ' + skipped.join(', ') + ' — each needs a matching key under info.parts with a usable xyxy (see warnings)' }; }
11979
+ return { success: true, ...result };
10803
11980
  })();`.trim();
10804
11981
  }
10805
11982
  /**
@@ -10902,7 +12079,9 @@ ${needWorld}
10902
12079
  (function() {
10903
12080
  if (typeof app.addWorldActor !== 'function') { return { success: false, error: 'app.addWorldActor unavailable — update FxTool' }; }
10904
12081
  ${needWorld}
10905
- const id = app.addWorldActor(${actor});
12082
+ const __r = app.addWorldActor(${actor});
12083
+ // The engine may return the actor itself; the id is what later calls take (gate D5).
12084
+ const id = (__r && typeof __r === 'object') ? (__r.id || null) : __r;
10906
12085
  return id ? { success: true, actorId: id } : { success: false, error: 'actor not added — is the sprite id a canvas item?' };
10907
12086
  })();`.trim();
10908
12087
  }
@@ -10940,7 +12119,9 @@ ${needWorld}
10940
12119
  // World3D: place an object (y defaults to sitting on the terrain)
10941
12120
  (function() {
10942
12121
  ${needWorld}
10943
- const id = app.addWorldObject(${S(world3dColors(input.object))});
12122
+ const __o = app.addWorldObject(${S(world3dColors(input.object))});
12123
+ // addWorldObject returns the OBJECT; its id is what set/remove calls take (gate D5).
12124
+ const id = (__o && typeof __o === 'object') ? (__o.id || null) : __o;
10944
12125
  return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
10945
12126
  })();`.trim();
10946
12127
  case 'remove_object':
@@ -10955,7 +12136,10 @@ ${needWorld}
10955
12136
  // World3D: tear the world down — Paper's canvas is untouched (it was always its own layer)
10956
12137
  (function() {
10957
12138
  if (typeof app.removeWorld3D !== 'function') { return { success: false, error: 'app.removeWorld3D unavailable — update FxTool' }; }
10958
- return { success: !!app.removeWorld3D() };
12139
+ // removeWorld3D's return value is not a success flag; whether the world is
12140
+ // gone is (gate D5: it worked and reported failure).
12141
+ app.removeWorld3D();
12142
+ return { success: !app._world3d };
10959
12143
  })();`.trim();
10960
12144
  // --- Mesh authoring ---------------------------------------------------
10961
12145
  // extrudeToMesh / latheToMesh REFUSE by name when there is no world
@@ -11008,7 +12192,19 @@ ${needWorld}
11008
12192
  // World3D: every authored mesh on the stage
11009
12193
  (function() {
11010
12194
  if (typeof app.listWorldMeshes !== 'function') { return { success: false, error: 'app.listWorldMeshes unavailable — update FxTool' }; }
11011
- return { success: true, meshes: app.listWorldMeshes() };
12195
+ // Summarised (gate A, 1.6.19): one star returned over 10 KB, nearly all of it
12196
+ // collision and vertex arrays nobody asked for. Arrays longer than 16 numbers
12197
+ // become their length; everything else passes as is.
12198
+ const __slim = function (v, depth) {
12199
+ // Typed arrays too: collision positions are a Float32Array, which
12200
+ // Array.isArray misses, so a star still came back as 12 KB (gate run 2).
12201
+ if (ArrayBuffer.isView(v)) return v.length > 16 ? { length: v.length } : Array.prototype.slice.call(v);
12202
+ if (Array.isArray(v)) return v.length > 16 ? { length: v.length } : v.map(function (x) { return __slim(x, depth + 1); });
12203
+ if (v && typeof v === 'object' && depth < 4) { const o = {}; for (const k in v) { if (typeof v[k] !== 'function') o[k] = __slim(v[k], depth + 1); } return o; }
12204
+ return v;
12205
+ };
12206
+ const __m = app.listWorldMeshes() || [];
12207
+ return { success: true, count: __m.length, meshes: __m.map(function (m) { return __slim(m, 0); }) };
11012
12208
  })();`.trim();
11013
12209
  case 'remove_mesh':
11014
12210
  return `
@@ -11178,7 +12374,10 @@ ${needWorld}
11178
12374
  (function() {
11179
12375
  if (typeof app.worldToCanvas !== 'function') { return { success: false, error: 'app.worldToCanvas unavailable — update FxTool' }; }
11180
12376
  ${needWorld}
11181
- return { success: true, point: app.worldToCanvas(${S(input.point)}) };
12377
+ // projectToCanvas destructures [x, y, z]; an {x, y, z} object threw "is not iterable" (gate D5).
12378
+ const __p = ${S(input.point)};
12379
+ const __pt = app.worldToCanvas(Array.isArray(__p) ? __p : [__p.x, __p.y, __p.z ?? 0]);
12380
+ return __pt ? { success: true, point: Array.isArray(__pt) ? { x: __pt[0], y: __pt[1] } : __pt } : { success: false, error: 'the point is not in front of the camera, or there is no world' };
11182
12381
  })();`.trim();
11183
12382
  case 'canvas_to_ground':
11184
12383
  return `
@@ -11285,14 +12484,20 @@ ${needWorld}
11285
12484
  * already decided by a pure function, so nothing is left for the page to
11286
12485
  * work out.
11287
12486
  */
11288
- generateDesignCompose(scene, ops, style) {
12487
+ generateDesignCompose(scene, ops, style, sizeWarning) {
11289
12488
  const S = (v) => JSON.stringify(v);
11290
12489
  return `
11291
12490
  // Design: compose a ${style} scene — ${ops.length} items, laid out server-side
11292
12491
  (function() {
11293
12492
  if (typeof app.create !== 'function') { return { success: false, error: 'app.create unavailable — update FxTool' }; }
11294
12493
  const size = ${S({ width: scene.width, height: scene.height })};
11295
- if (typeof app.setCanvasSize === 'function') { try { app.setCanvasSize(size.width, size.height); } catch (_) { /* a fixed canvas is not a failure */ } }
12494
+ // The layout was computed for this size, so the canvas is set to it — and
12495
+ // the result SAYS so: a 1080x1080 canvas became 1280x720 silently (gate C).
12496
+ const __before = app.getCanvasSize ? app.getCanvasSize() : null;
12497
+ if (typeof app.setCanvasSize === 'function') { try { app.setCanvasSize({ width: size.width, height: size.height }); } catch (_) { /* a fixed canvas is not a failure */ } }
12498
+ const __after = app.getCanvasSize ? app.getCanvasSize() : null;
12499
+ const canvasResized = __before && __after && (__before.width !== __after.width || __before.height !== __after.height)
12500
+ ? { from: { width: __before.width, height: __before.height }, to: { width: __after.width, height: __after.height } } : null;
11296
12501
  ${scene.backgroundHex ? `if (typeof app.setBackgroundColor === 'function') { try { app.setBackgroundColor(${S(scene.backgroundHex)}); } catch (_) { /* ditto */ } }` : '// this style paints no background of its own'}
11297
12502
  const ops = ${S(ops)};
11298
12503
  const ids = [];
@@ -11307,7 +12512,9 @@ ${needWorld}
11307
12512
  if (app.historyManager) app.historyManager.saveState();
11308
12513
  // A partial composition is reported as one. Half a poster that claims
11309
12514
  // success is worse than a poster that says which four elements are missing.
11310
- return { success: failed.length === 0, style: ${S(style)}, itemIds: ids, itemCount: ids.length, failed: failed };
12515
+ return { success: failed.length === 0, style: ${S(style)}, itemIds: ids, itemCount: ids.length, failed: failed,
12516
+ ...(canvasResized ? { canvasResized: canvasResized, note: 'the canvas was resized to the composition' + (${S(sizeWarning ?? '')} ? '' : '; pass width and height to compose at your canvas size') } : {}),
12517
+ ...(${S(sizeWarning ?? '')} ? { warning: ${S(sizeWarning ?? '')} } : {}) };
11311
12518
  })();`.trim();
11312
12519
  }
11313
12520
  /**
@@ -11378,7 +12585,14 @@ ${needWorld}
11378
12585
  */
11379
12586
  generateStory(input) {
11380
12587
  const S = (v) => JSON.stringify(v);
11381
- const opts = S(input.options ?? {});
12588
+ // nav decides the story's FORM (D109): 'scenes' = a scene chain (video),
12589
+ // 'panels' = tabs (widget). storyFromText reads options.nav; a spec carries
12590
+ // its own nav, which a top-level nav fills in when the spec has none.
12591
+ const opts = S({ ...(input.options ?? {}), ...(input.nav ? { nav: input.nav } : {}) });
12592
+ const spec = input.spec && input.nav && input.spec.nav === undefined ? { ...input.spec, nav: input.nav } : input.spec;
12593
+ const cuts = input.nav === 'scenes'
12594
+ ? `\n // The video form's cut times (s), for checking the edit.\n try { if (app.sceneManager && app.sceneManager.getChainCuts) __out.cuts = app.sceneManager.getChainCuts(); } catch (_) {}`
12595
+ : '';
11382
12596
  switch (input.action) {
11383
12597
  case 'distill':
11384
12598
  return `
@@ -11404,16 +12618,18 @@ ${needWorld}
11404
12618
  // fine in-page. Not a cycle; JSON.stringify succeeded on this the whole time.
11405
12619
  // The groups have to stay for in-page callers, so the wrapper takes what
11406
12620
  // travels: the spec, the ids, the count.
11407
- return { success: true, spec: r.spec, partIds: r.partIds, partCount: r.partCount, beats: r.beats };
12621
+ const __out = { success: true, nav: (r.spec && r.spec.nav) || ${S(input.nav ?? 'panels')}, spec: r.spec, partIds: r.partIds, partCount: r.partCount, beats: r.beats };${cuts}
12622
+ return __out;
11408
12623
  })();`.trim();
11409
12624
  case 'apply_spec':
11410
12625
  return `
11411
12626
  // Story: assemble a spec you already have
11412
12627
  (async function() {
11413
12628
  if (typeof app.applyStorySpec !== 'function') { return { success: false, error: 'app.applyStorySpec unavailable — update FxTool' }; }
11414
- const r = await app.applyStorySpec(${S(input.spec)}, ${opts});
12629
+ const r = await app.applyStorySpec(${S(spec)}, ${opts});
11415
12630
  if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the spec did not assemble' }; }
11416
- return { success: true, ...r };
12631
+ const __out = { success: true, ...r };${cuts}
12632
+ return __out;
11417
12633
  })();`.trim();
11418
12634
  case 'plan_book':
11419
12635
  return `
@@ -11448,9 +12664,13 @@ ${needWorld}
11448
12664
  // Interchange: ${fn}
11449
12665
  (async function() {
11450
12666
  ${guard(fn)}
12667
+ ${TO_DATA_URL_JS}
11451
12668
  const out = await app.${fn}(${opts});
11452
12669
  if (!out) { return { success: false, error: '${fn} produced nothing — the scene may have no animatable content' }; }
11453
- return { success: true, format: ${S(input.action === 'export_lottie' ? 'lottie' : 'dotlottie')}, data: out };
12670
+ ${input.action === 'export_lottie'
12671
+ ? `return { success: true, format: 'lottie', data: out };`
12672
+ : `const __url = await __ppToDataUrl(out, 'application/zip');
12673
+ return __url ? { success: true, format: 'dotlottie', data: __url } : { success: false, error: 'exportDotLottie returned something that is not binary' };`}
11454
12674
  })();`.trim();
11455
12675
  }
11456
12676
  case 'import_lottie':
@@ -11460,9 +12680,23 @@ ${guard(fn)}
11460
12680
  ${guard('importLottie')}
11461
12681
  // importLottie logs and returns falsy when the importer is absent — a
11462
12682
  // stripped console makes that indistinguishable from an empty animation.
12683
+ ${REG_ID_JS}
11463
12684
  const r = await app.importLottie(${S(input.data)}, ${opts});
11464
- if (!r) { return { success: false, error: 'the Lottie did not import — it may be malformed, or this build has no Lottie importer' }; }
11465
- return { success: true, imported: r };
12685
+ // importLottie ALWAYS answers { success, items, duration, warnings, errors };
12686
+ // only !r was checked, so a failed import read as a success, and the ids
12687
+ // were looked for on fields it does not have (gate N2: itemId null).
12688
+ if (!r || r.success === false) {
12689
+ const why = r && Array.isArray(r.errors) && r.errors.length ? r.errors.join('; ') : 'the Lottie did not import — it may be malformed';
12690
+ return { success: false, error: why, ...(r && r.warnings && r.warnings.length ? { warnings: r.warnings } : {}) };
12691
+ }
12692
+ const __items = Array.isArray(r.items) ? r.items : [];
12693
+ const itemIds = __items.map(function (it) { return __ppRegId(it); }).filter(Boolean);
12694
+ if (!__items.length) return { success: false, error: 'the Lottie imported no items' };
12695
+ let __b = null;
12696
+ __items.forEach(function (it) { if (it && it.bounds) __b = __b ? __b.unite(it.bounds) : it.bounds; });
12697
+ return { success: true, itemId: itemIds[0] || null, itemIds: itemIds, items: __items.length, duration: r.duration,
12698
+ bounds: __b ? { x: __b.x, y: __b.y, width: __b.width, height: __b.height } : undefined,
12699
+ ...(r.warnings && r.warnings.length ? { warnings: r.warnings } : {}) };
11466
12700
  })();`.trim();
11467
12701
  case 'export_glb':
11468
12702
  return `
@@ -11473,9 +12707,11 @@ ${guard('exportGLB')}
11473
12707
  // stripped in production, so the precondition is checked here instead.
11474
12708
  const has3d = app._initialized && app._initialized.has && app._initialized.has('threeD');
11475
12709
  if (!has3d) { return { success: false, error: 'no perspective objects to export — create one with createObject3D first' }; }
12710
+ ${TO_DATA_URL_JS}
11476
12711
  const out = await app.exportGLB(${opts});
11477
12712
  if (!out) { return { success: false, error: 'GLB export produced nothing' }; }
11478
- return { success: true, format: 'glb', data: out };
12713
+ const __url = await __ppToDataUrl(out, 'model/gltf-binary');
12714
+ return __url ? { success: true, format: 'glb', data: __url } : { success: true, format: 'glb', data: out };
11479
12715
  })();`.trim();
11480
12716
  case 'export_bvh':
11481
12717
  return `
@@ -11536,7 +12772,16 @@ ${guard('exportPNGSequence')}
11536
12772
  generateSound(input) {
11537
12773
  const S = (v) => JSON.stringify(v);
11538
12774
  const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
11539
- const simple = (fn, args, key, label) => `
12775
+ const simple = (fn, args, key, label) => key === 'played' ? `
12776
+ // Sound: ${label}
12777
+ (function() {
12778
+ ${guard(fn)}
12779
+ // A play that returns no voice played nothing, and reported success (gate
12780
+ // sweep D, 1.6.19). A headless studio may have no audio output at all.
12781
+ const __v = app.${fn}(${args});
12782
+ if (__v == null) { return { success: false, error: 'nothing played: the studio returned no voice (it may have no audio output). Placed sounds (create, sequence) and render_soundtrack work without one.' }; }
12783
+ return { success: true, played: __v };
12784
+ })();`.trim() : `
11540
12785
  // Sound: ${label}
11541
12786
  (function() {
11542
12787
  ${guard(fn)}
@@ -11551,8 +12796,12 @@ ${guard(fn)}
11551
12796
  case 'chord_frequencies': return simple('chordFrequencies', `${S(input.root)}, ${S(input.chord)}, ${S(input.options ?? {})}`, 'frequencies', `the Hz of ${input.chord} on ${input.root}`);
11552
12797
  case 'play_percussion': return simple('playPercussion', `${S(input.name)}, ${S(input.options ?? {})}`, 'played', `percussion ${input.name}`);
11553
12798
  case 'play_sfx': return simple('playSfx', `${S(input.name)}, ${S(input.options ?? {})}`, 'played', `sfx ${input.name}`);
11554
- case 'play_spec': return simple('playSound', S(input.spec), 'played', 'a raw spec');
11555
- case 'play_from_text': return simple('playSoundFromText', S(input.text), 'played', `"${input.text}"`);
12799
+ // options are merged into the spec, as play_tone's are; play_spec and
12800
+ // play_from_text dropped them (gate sweep D).
12801
+ case 'play_spec': return simple('playSound', `Object.assign({}, ${S(input.spec)}, ${S(input.options ?? {})})`, 'played', 'a raw spec');
12802
+ case 'play_from_text': return input.options
12803
+ ? simple('playSound', `Object.assign({}, app.buildSoundFromText(${S(input.text)}) || {}, ${S(input.options)})`, 'played', 'a described sound')
12804
+ : simple('playSoundFromText', S(input.text), 'played', 'a described sound');
11556
12805
  case 'from_text':
11557
12806
  return `
11558
12807
  // Sound: resolve "${input.text}" to a spec WITHOUT playing it
@@ -11712,7 +12961,9 @@ ${guard('createSound')}
11712
12961
  for (let i = 0; i < cues.length; i++) {
11713
12962
  const c = cues[i];
11714
12963
  try {
11715
- const spec = c.spec || (c.preset ? { preset: c.preset, note: c.note } : null);
12964
+ // The engine reads spec.instrument and never preset, so a preset cue
12965
+ // played a plain sine (gate sweep D).
12966
+ const spec = c.spec || (c.preset ? { instrument: c.preset, note: c.note } : null);
11716
12967
  if (!spec) { failed.push({ index: i, t: c.t, error: 'neither spec nor preset' }); continue; }
11717
12968
  const item = app.createSound(spec, c.visual || {});
11718
12969
  if (!item) { failed.push({ index: i, t: c.t, error: 'the cue produced no waveform path' }); continue; }
@@ -11801,10 +13052,20 @@ ${guard('timbreFromPath')}
11801
13052
  generatePathOp(input) {
11802
13053
  const S = (v) => JSON.stringify(v);
11803
13054
  const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
13055
+ // Paper items in the engine's answer become their registry ids: spread
13056
+ // as-is they serialised to raw Paper dumps (1.6.19 gate: boolean,
13057
+ // outline_stroke and pattern).
11804
13058
  const pass = (expr) => `
13059
+ ${REG_ID_JS}
13060
+ const __isItem = function (v) { return v && typeof v === 'object' && typeof v.className === 'string' && 'bounds' in v; };
13061
+ const __clean = function (v) { return __isItem(v) ? (__ppRegId(v) || ('paper:' + v.id)) : (Array.isArray(v) && v.some(__isItem) ? v.map(__clean) : v); };
11805
13062
  const r = ${expr};
11806
13063
  if (!r || r.ok === false) { return { success: false, error: (r && r.reason) || 'the engine refused the operation' }; }
11807
- return { success: true, ...r };`;
13064
+ const out = { success: true };
13065
+ Object.keys(r).forEach(function (k) { out[k] = __clean(r[k]); });
13066
+ // itemId, as every other tool names it; the engine says id (gate run 3).
13067
+ if (out.itemId === undefined && typeof out.id === 'string') out.itemId = out.id;
13068
+ return out;`;
11808
13069
  switch (input.action) {
11809
13070
  case 'boolean':
11810
13071
  return `
@@ -12146,7 +13407,14 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
12146
13407
  return wrap('Rigging: bake animation to keyframes', ` const res = R.bakeAnimation(${S(input.skeletonId)}, ${S(input.options ?? {})});
12147
13408
  if (res && app.historyManager) app.historyManager.saveState();
12148
13409
  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." }; }
12149
- return { success: true, action: 'bake_animation', result: res };`);
13410
+ // bakeAnimation returns a MAP (itemId -> keyframes), which serialised to {},
13411
+ // and its report is a non-enumerable bakeReport (gate run 3). Read both.
13412
+ const rep = res.bakeReport || {};
13413
+ const detached = rep.detached || 0;
13414
+ return { success: (rep.written || 0) > 0 || ${input.options && input.options.write === false ? 'true' : 'false'}, action: 'bake_animation',
13415
+ items: res.size, written: rep.written || 0, keyframes: rep.keyframes || 0, cleared: rep.cleared || 0, detached: detached,
13416
+ wrote: rep.wrote || [], ...(rep.skipped && rep.skipped.length ? { skipped: rep.skipped } : {}),
13417
+ ...(detached ? { note: detached + ' item(s) were DETACHED from the rig: their motion is now keyframes, and the bones no longer drive them. Pass options.detach:false to keep the rig driving.' } : {}) };`);
12150
13418
  case 'list_shape_keys':
12151
13419
  return wrap('Rigging: list shape keys', ` const keys = R.listShapeKeys(${S(input.skeletonId)});
12152
13420
  return { success: true, action: 'list_shape_keys', shapeKeys: keys, count: keys.length };`);
@@ -12380,7 +13648,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
12380
13648
  generateEvent(input) {
12381
13649
  if (input.action === 'create') {
12382
13650
  const nameJson = JSON.stringify(input.name);
12383
- const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y });
13651
+ const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y, ...(input.at !== undefined ? { at: input.at } : {}) });
12384
13652
  return `
12385
13653
  // Create event: ${input.name}
12386
13654
  (function() {
@@ -12390,7 +13658,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
12390
13658
  const eventId = app.createEvent(${nameJson}, ${optsJson});
12391
13659
  if (!eventId) { return { success: false, error: 'Event creation failed' }; }
12392
13660
  if (app.historyManager) app.historyManager.saveState();
12393
- return { success: true, action: 'create', eventId };
13661
+ return { success: true, action: 'create', eventId${input.at !== undefined ? `, at: ${JSON.stringify(input.at)}` : ''} };
12394
13662
  })();`.trim();
12395
13663
  }
12396
13664
  // pulse — a runtime fire, not a scene edit (no history save)