@pinepaper.studio/mcp-server 1.6.11 → 1.6.13

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 (70) hide show
  1. package/README.md +219 -0
  2. package/dist/agent/export-manager.d.ts.map +1 -1
  3. package/dist/agent/export-manager.js +9 -7
  4. package/dist/agent/export-manager.js.map +1 -1
  5. package/dist/assets/repositories/iconify.d.ts +0 -5
  6. package/dist/assets/repositories/iconify.d.ts.map +1 -1
  7. package/dist/assets/repositories/iconify.js +17 -5
  8. package/dist/assets/repositories/iconify.js.map +1 -1
  9. package/dist/browser/puppeteer-controller.d.ts +16 -0
  10. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  11. package/dist/browser/puppeteer-controller.js +32 -2
  12. package/dist/browser/puppeteer-controller.js.map +1 -1
  13. package/dist/design/design-systems.d.ts +27 -2
  14. package/dist/design/design-systems.d.ts.map +1 -1
  15. package/dist/design/design-systems.js +20 -0
  16. package/dist/design/design-systems.js.map +1 -1
  17. package/dist/tools/compact-descriptions.js +1 -1
  18. package/dist/tools/compact-descriptions.js.map +1 -1
  19. package/dist/tools/definitions.d.ts.map +1 -1
  20. package/dist/tools/definitions.js +175 -52
  21. package/dist/tools/definitions.js.map +1 -1
  22. package/dist/tools/engine-methods.d.ts.map +1 -1
  23. package/dist/tools/engine-methods.js +8 -0
  24. package/dist/tools/engine-methods.js.map +1 -1
  25. package/dist/tools/engine-surface.d.ts +1 -1
  26. package/dist/tools/engine-surface.d.ts.map +1 -1
  27. package/dist/tools/engine-surface.js +32 -12
  28. package/dist/tools/engine-surface.js.map +1 -1
  29. package/dist/tools/handlers.d.ts +13 -2
  30. package/dist/tools/handlers.d.ts.map +1 -1
  31. package/dist/tools/handlers.js +219 -13
  32. package/dist/tools/handlers.js.map +1 -1
  33. package/dist/tools/minimal-descriptions.js +1 -1
  34. package/dist/tools/minimal-descriptions.js.map +1 -1
  35. package/dist/tools/shape-params.d.ts +36 -0
  36. package/dist/tools/shape-params.d.ts.map +1 -0
  37. package/dist/tools/shape-params.js +159 -0
  38. package/dist/tools/shape-params.js.map +1 -0
  39. package/dist/tools/stick-vocabulary.d.ts +26 -0
  40. package/dist/tools/stick-vocabulary.d.ts.map +1 -0
  41. package/dist/tools/stick-vocabulary.js +102 -0
  42. package/dist/tools/stick-vocabulary.js.map +1 -0
  43. package/dist/tools/toolkits.d.ts.map +1 -1
  44. package/dist/tools/toolkits.js +10 -1
  45. package/dist/tools/toolkits.js.map +1 -1
  46. package/dist/types/code-generator.d.ts.map +1 -1
  47. package/dist/types/code-generator.js +865 -122
  48. package/dist/types/code-generator.js.map +1 -1
  49. package/dist/types/schemas.d.ts +191 -35
  50. package/dist/types/schemas.d.ts.map +1 -1
  51. package/dist/types/schemas.js +32 -16
  52. package/dist/types/schemas.js.map +1 -1
  53. package/dist/vendor/design/design-systems-dtcg.d.ts +9 -0
  54. package/dist/vendor/design/design-systems-dtcg.d.ts.map +1 -1
  55. package/dist/vendor/design/design-systems-dtcg.js +53 -1
  56. package/dist/vendor/design/design-systems-dtcg.js.map +1 -1
  57. package/dist/vendor/design/device-mockup-generator.d.ts +83 -0
  58. package/dist/vendor/design/device-mockup-generator.d.ts.map +1 -0
  59. package/dist/vendor/design/device-mockup-generator.js +634 -0
  60. package/dist/vendor/design/device-mockup-generator.js.map +1 -0
  61. package/dist/vendor/design/recursive-subdivision-generator.d.ts +79 -0
  62. package/dist/vendor/design/recursive-subdivision-generator.d.ts.map +1 -0
  63. package/dist/vendor/design/recursive-subdivision-generator.js +319 -0
  64. package/dist/vendor/design/recursive-subdivision-generator.js.map +1 -0
  65. package/dist/vendor/design/textile-weave-generator.d.ts +140 -0
  66. package/dist/vendor/design/textile-weave-generator.d.ts.map +1 -0
  67. package/dist/vendor/design/textile-weave-generator.js +568 -0
  68. package/dist/vendor/design/textile-weave-generator.js.map +1 -0
  69. package/manifest.json +1 -1
  70. package/package.json +8 -2
@@ -86,7 +86,7 @@ function generateCreateItemCode(itemType, position, properties) {
86
86
  const params = {
87
87
  x: position.x,
88
88
  y: position.y,
89
- ...baseProperties,
89
+ ...withRadiusAxes(baseProperties),
90
90
  };
91
91
  // Handle simple solid colors in params
92
92
  if (color && !isGradient(color)) {
@@ -128,6 +128,50 @@ function generateCreateItemCode(itemType, position, properties) {
128
128
  const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
129
129
  if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }`;
130
130
  }
131
+ // RTL IS NOT SUPPORTED, AND SILENCE WAS THE WORST WAY TO SAY SO.
132
+ //
133
+ // create('text', {direction:'rtl'}) dropped the property with no error and
134
+ // left the base direction LTR, so an Arabic sentence's final period landed
135
+ // at the reader's START — visibly wrong, and attributable to nothing. The
136
+ // engine has no text-direction support at all: `direction` there is an arc
137
+ // sweep and an animation direction, and `justification` is the only
138
+ // text-related alignment it reads.
139
+ //
140
+ // Refused by name rather than accepted-and-dropped, and rather than adding a
141
+ // prop that would be dropped one layer further on. The alignment half is
142
+ // real and is named, because it is what a caller can actually use today.
143
+ const rtlAsked = params.direction ?? params.textDirection;
144
+ if (rtlAsked !== undefined) {
145
+ return `
146
+ // Create ${itemType} — refused, rather than silently left ltr
147
+ ({ success: false, error: ${JSON.stringify(`direction / textDirection is not supported: this studio has no text-direction support, so ${String(rtlAsked)} would have been accepted and the text left LTR. `
148
+ + 'That is visible as a right-to-left sentence whose final punctuation lands at the start. '
149
+ + "You can align with justification: 'right', which is read, but the BASE direction needs engine support — "
150
+ + 'pass pre-shaped text, or split the line, until it lands.')} });`.trim();
151
+ }
152
+ // AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
153
+ //
154
+ // The engine names a bad anchor through console.warn and leaves (x, y) as
155
+ // the centre — good behaviour in the editor, and nothing at all here, since
156
+ // the production build strips console.warn. A typo would therefore displace
157
+ // the item by half its own size, which is the exact bug `anchor` exists to
158
+ // remove, arriving silently. Checked before it is sent instead.
159
+ const ANCHORS = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'center', 'centre'];
160
+ const anchorGiven = params.anchor ?? params.origin;
161
+ if (anchorGiven !== undefined) {
162
+ const normalized = String(anchorGiven).toLowerCase().replace(/[\s_]+/g, '-');
163
+ if (!ANCHORS.includes(normalized)) {
164
+ return `
165
+ // Create ${itemType} — refused, rather than silently centred
166
+ ({ success: false, error: ${JSON.stringify(`anchor ${JSON.stringify(String(anchorGiven))} is not one of ${ANCHORS.slice(0, 5).join(', ')}. `
167
+ + 'The engine would leave x/y as the CENTRE and say so only in a console this tool cannot read, '
168
+ + 'so the item would land half its own size away with nothing to explain it.')} });`.trim();
169
+ }
170
+ if (params.anchor !== undefined)
171
+ params.anchor = normalized;
172
+ if (params.origin !== undefined)
173
+ params.origin = normalized;
174
+ }
131
175
  // Build the code
132
176
  let code = `
133
177
  // Create ${itemType} item
@@ -393,6 +437,28 @@ function normalizeKeyframePositions(keyframes) {
393
437
  /**
394
438
  * Template for keyframe animation
395
439
  */
440
+ /**
441
+ * AN UNKNOWN itemId IS A SILENT SUCCESS, ACROSS A WHOLE FAMILY OF TOOLS.
442
+ *
443
+ * `app.addAnimation('c0', …)` on an id that does not exist calls
444
+ * console.warn and returns undefined — and production strips console.warn, so
445
+ * over MCP nothing is said at all. The emitter then returned
446
+ * `{success: true}` because the call did not throw. A pilot animated a
447
+ * connector id that was never registered and got success for every keyframe.
448
+ *
449
+ * Emitted as a shared preamble so the check reads the same everywhere and a
450
+ * new tool taking an itemId can adopt it in one line. It resolves through the
451
+ * registry the way the engine does, and names the id it could not find —
452
+ * "item_7 is not on the canvas" is actionable, silence is not.
453
+ */
454
+ function requireItem(itemId, what) {
455
+ return `
456
+ const _target = (app.getItemById && app.getItemById(${JSON.stringify(itemId)}))
457
+ || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}));
458
+ if (!_target) {
459
+ return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
460
+ }`.trim();
461
+ }
396
462
  function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
397
463
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
398
464
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
@@ -407,9 +473,11 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
407
473
  opts.timeUnits = timeUnits;
408
474
  return `
409
475
  // Apply keyframe animation to ${itemId}
410
- app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
411
-
412
- ({ success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''} });
476
+ (function() {
477
+ ${requireItem(itemId, 'the animation')}
478
+ app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
479
+ return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''} };
480
+ })();
413
481
  `.trim();
414
482
  }
415
483
  /**
@@ -475,8 +543,62 @@ app.applyEffect(item, '${effectType}', ${JSON.stringify(params, null, 2)});
475
543
  /**
476
544
  * Template for getting items
477
545
  */
546
+ /**
547
+ * radiusX / radiusY REACH NOTHING.
548
+ *
549
+ * The engine's create() feeds a shape's config from width, height and radius.
550
+ * radiusX and radiusY are not among them, so an ellipse asked for
551
+ * `{radiusX: 250, radiusY: 72}` was built at the DEFAULT 100x60 and reported
552
+ * success — measured from production as "about 90x50". This package documented
553
+ * that exact pair as the ellipse's properties and used it in two worked
554
+ * examples, so a model following the guide got the wrong size every time and
555
+ * nothing anywhere said why.
556
+ *
557
+ * Mapped rather than merely re-documented: the radii are the natural way to
558
+ * describe an ellipse, the conversion is unambiguous (a radius is half its
559
+ * axis), and a caller who already wrote radiusX should not have to rewrite it.
560
+ * An explicit width/height wins, so nothing that works today changes.
561
+ *
562
+ * A COMPATIBILITY SHIM WITH A REMOVAL CONDITION. FxTool aaaf98ae aliases the
563
+ * same pair in the engine's shared param normaliser, which is the better place
564
+ * — it fixes every door at once, not just this one. This stays until that
565
+ * reaches the studios this package is pointed at, which is not the same moment
566
+ * it reaches origin/main: mcp-server ships independently and drives whatever
567
+ * studio it is given. The two cannot fight, because this deletes radiusX
568
+ * before the call, so the engine's alias never sees it.
569
+ *
570
+ * Delete this when a studio without aaaf98ae is no longer worth supporting.
571
+ */
572
+ function withRadiusAxes(props) {
573
+ const { radiusX, radiusY, ...rest } = props;
574
+ if (radiusX === undefined && radiusY === undefined)
575
+ return props;
576
+ const out = { ...rest };
577
+ if (typeof radiusX === 'number' && out.width === undefined)
578
+ out.width = radiusX * 2;
579
+ if (typeof radiusY === 'number' && out.height === undefined)
580
+ out.height = radiusY * 2;
581
+ return out;
582
+ }
478
583
  function generateGetItemsCode(filter) {
479
- if (!filter || Object.keys(filter).length === 0) {
584
+ // A filter OBJECT is not the same as a filter. `{}` is already handled, but
585
+ // so is `{ type: undefined }` and anything whose keys this function does not
586
+ // recognise: those have a non-zero key count and contribute no conditions,
587
+ // and the emitted `entries.filter(entry => )` is a SYNTAX ERROR rather than
588
+ // a wrong answer — the whole call dies before it runs. Decide on the
589
+ // conditions, not on the keys.
590
+ const conditions = [];
591
+ if (filter?.type)
592
+ conditions.push(`entry.type === '${filter.type}'`);
593
+ if (filter?.source)
594
+ conditions.push(`entry.source === '${filter.source}'`);
595
+ if (filter?.hasAnimation !== undefined) {
596
+ conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
597
+ }
598
+ if (filter?.hasRelation !== undefined) {
599
+ conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
600
+ }
601
+ if (conditions.length === 0) {
480
602
  return `
481
603
  // Get all items
482
604
  const entries = app.itemRegistry.getAll();
@@ -491,19 +613,6 @@ const items = entries.map(entry => ({
491
613
  ({ items, count: items.length });
492
614
  `.trim();
493
615
  }
494
- const conditions = [];
495
- if (filter.type) {
496
- conditions.push(`entry.type === '${filter.type}'`);
497
- }
498
- if (filter.source) {
499
- conditions.push(`entry.source === '${filter.source}'`);
500
- }
501
- if (filter.hasAnimation !== undefined) {
502
- conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
503
- }
504
- if (filter.hasRelation !== undefined) {
505
- conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
506
- }
507
616
  return `
508
617
  // Get items with filter
509
618
  const entries = app.itemRegistry.getAll();
@@ -801,15 +910,51 @@ const stats = app.getRelationStats();
801
910
  function generateListGeneratorsCode() {
802
911
  return `
803
912
  // List available generators
804
- const generators = app.getAvailableBackgroundGenerators();
805
- const formatted = generators.map(g => ({
806
- name: g.name,
807
- displayName: g.displayName || g.name,
808
- category: g.category || 'background',
809
- description: g.description || ''
810
- }));
913
+ //
914
+ // THREE OF SEVENTY-FOUR. getAvailableBackgroundGenerators() returned a
915
+ // hand-written literal of three names while the registry held 74 — nothing was
916
+ // broken, the other seventy-one executed fine BY NAME, they were simply
917
+ // invisible to anyone asking what exists. listGenerators() merges the registry
918
+ // and awaits it; it is newer than some studios, so the old call stays as the
919
+ // fallback and the result says which answer you got.
920
+ //
921
+ // Wrapped in an async IIFE deliberately: a bare top-level await parses under
922
+ // the governor and is a syntax error on the eval path.
923
+ (async function() {
924
+ let generators;
925
+ let complete = true;
926
+ if (typeof app.listGenerators === 'function') {
927
+ generators = await app.listGenerators();
928
+ } else {
929
+ generators = app.getAvailableBackgroundGenerators();
930
+ // The merged array carries a non-enumerable pending flag when the registry
931
+ // chunk has not landed, so a cold page honestly answers three.
932
+ complete = !generators || generators.pending !== true;
933
+ }
934
+ // PARAMS ARE THE WHOLE POINT OF ASKING.
935
+ //
936
+ // The tool says it lists generators "with their parameters" and this mapper
937
+ // dropped the params key, so every answer was a name and a sentence. A
938
+ // caller who wanted to know what drawGradientMesh accepts had to guess, and
939
+ // an unknown generatorParams key is accepted in silence — so a guess that
940
+ // was wrong looked exactly like one that was right.
941
+ const formatted = (generators || []).map(g => ({
942
+ name: g.name,
943
+ displayName: g.displayName || g.name,
944
+ category: g.category || 'background',
945
+ description: g.description || '',
946
+ ...(g.params ? { params: g.params } : {})
947
+ }));
811
948
 
812
- ({ generators: formatted });
949
+ return {
950
+ generators: formatted,
951
+ count: formatted.length,
952
+ ...(complete ? {} : {
953
+ partial: true,
954
+ note: 'the generator registry had not finished loading, so this is a partial list — the built-ins only. Retry for the full set.',
955
+ }),
956
+ };
957
+ })();
813
958
  `.trim();
814
959
  }
815
960
  /**
@@ -1041,6 +1186,107 @@ const ENSURE_EXPORT_ENGINE = `
1041
1186
  if (!app.exportEngine && typeof app.ensureHeavyModules === 'function') {
1042
1187
  try { await app.ensureHeavyModules(); } catch (_) { /* the guards below still speak */ }
1043
1188
  }`;
1189
+ /**
1190
+ * A REMOTE <image> INSIDE AN SVG TAINTS THE CANVAS, PERMANENTLY.
1191
+ *
1192
+ * paper.importSVG draws a cross-origin raster straight onto the canvas. The
1193
+ * canvas then has an origin-dirty flag, and every export afterwards throws
1194
+ * "Tainted canvases may not be exported" — not just the one containing the
1195
+ * image. Nothing recovers it but a page reload, so ONE import poisons the
1196
+ * whole session, long after the call that did it returned success.
1197
+ *
1198
+ * Reported from production: import_svg with a picsum href answered
1199
+ * success/itemsCreated:0 and then broke every later export.
1200
+ *
1201
+ * So each remote href is fetched and inlined as a data: URL before the SVG
1202
+ * goes anywhere near the importer — the same thing import_image does, for the
1203
+ * same reason. An href that CANNOT be inlined has its element removed: a
1204
+ * missing picture is a visible, local, recoverable problem, and leaving it in
1205
+ * is an unrecoverable one that surfaces somewhere else entirely.
1206
+ */
1207
+ const INLINE_REMOTE_IMAGES = `
1208
+ async function inlineRemoteImages(svgText) {
1209
+ const notes = [];
1210
+ const hrefs = [];
1211
+ const re = /(?:xlink:)?href\\s*=\\s*("|')(https?:\\/\\/[^"']+)\\1/gi;
1212
+ let m;
1213
+ while ((m = re.exec(svgText)) !== null) if (hrefs.indexOf(m[2]) === -1) hrefs.push(m[2]);
1214
+ if (hrefs.length === 0) return { svg: svgText, notes: notes };
1215
+ for (const href of hrefs) {
1216
+ let dataUrl = null;
1217
+ try {
1218
+ const r = await fetch(href, { mode: 'cors' });
1219
+ if (r.ok) {
1220
+ const blob = await r.blob();
1221
+ dataUrl = await new Promise(function(res, rej) {
1222
+ const fr = new FileReader();
1223
+ fr.onload = function() { res(fr.result); };
1224
+ fr.onerror = function() { rej(new Error('could not read ' + href)); };
1225
+ fr.readAsDataURL(blob);
1226
+ });
1227
+ }
1228
+ } catch (e) { /* fall through to removal */ }
1229
+ if (dataUrl) {
1230
+ svgText = svgText.split(href).join(dataUrl);
1231
+ } else {
1232
+ // Drop the element rather than let it taint the canvas.
1233
+ const esc = href.replace(/[.*+?^\\\${}()|[\\]\\\\]/g, '\\\\$&');
1234
+ svgText = svgText.replace(new RegExp('<image\\\\b[^>]*' + esc + '[^>]*\\\\/?>', 'gi'), '');
1235
+ svgText = svgText.replace(new RegExp('<image\\\\b[^>]*' + esc + '[\\\\s\\\\S]*?<\\\\/image>', 'gi'), '');
1236
+ notes.push({
1237
+ url: href,
1238
+ action: 'removed',
1239
+ reason: 'unfetchable',
1240
+ message: 'removed an embedded image this page could not fetch (' + href + '). It was dropped rather than imported, because a cross-origin raster taints the canvas and every later export would have failed with "Tainted canvases may not be exported" until a reload.',
1241
+ });
1242
+ }
1243
+ }
1244
+ return { svg: svgText, notes: notes };
1245
+ }
1246
+ `;
1247
+ /**
1248
+ * A HEX COLOUR IN THE 3D WORLD POISONS EVERY LATER EXPORT.
1249
+ *
1250
+ * addWorldObject hands `color` to a WebGL uniform — its own example is
1251
+ * `color: [0.9, 0.3, 0.2]`, three floats in 0..1. Give it '#ef4444' and
1252
+ * uniform3fv throws "cannot be converted to a sequence"; the object is
1253
+ * reported as added, and then EVERY subsequent MP4 export fails, because the
1254
+ * broken uniform is in the scene from then on. Same shape as the tainted
1255
+ * canvas: one bad value, success reported, and the damage surfaces somewhere
1256
+ * else entirely.
1257
+ *
1258
+ * Converted here. A caller writing '#ef4444' is not wrong — it is the colour
1259
+ * spelling every other tool in this package takes — so the emitter absorbs the
1260
+ * difference rather than the caller learning a second convention.
1261
+ */
1262
+ function world3dColor(value) {
1263
+ if (Array.isArray(value)) {
1264
+ // Already a triple. Accept 0-255 too, since that is the other common form.
1265
+ const nums = value.filter((v) => typeof v === 'number');
1266
+ if (nums.length < 3)
1267
+ return value;
1268
+ return nums.slice(0, 3).map((n) => (n > 1 ? n / 255 : n));
1269
+ }
1270
+ if (typeof value !== 'string')
1271
+ return value;
1272
+ const hex = value.trim().replace(/^#/, '');
1273
+ const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
1274
+ if (!/^[0-9a-f]{6}$/i.test(full))
1275
+ return value;
1276
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
1277
+ }
1278
+ /** Recursively convert every `color` field in a world3d payload. */
1279
+ function world3dColors(value) {
1280
+ if (Array.isArray(value))
1281
+ return value.map((v) => world3dColors(v));
1282
+ if (!value || typeof value !== 'object')
1283
+ return value;
1284
+ const out = {};
1285
+ for (const [k, v] of Object.entries(value)) {
1286
+ out[k] = k === 'color' ? world3dColor(v) : world3dColors(v);
1287
+ }
1288
+ return out;
1289
+ }
1044
1290
  export class PinePaperCodeGenerator {
1045
1291
  /**
1046
1292
  * Generate code for creating an item
@@ -1265,7 +1511,10 @@ export class PinePaperCodeGenerator {
1265
1511
  if (!response.ok) {
1266
1512
  return { success: false, error: 'the server refused ${url} — HTTP ' + response.status + ' ' + (response.statusText || ''), status: response.status };
1267
1513
  }
1268
- const svgText = await response.text();
1514
+ let svgText = await response.text();
1515
+ ${INLINE_REMOTE_IMAGES}
1516
+ const _inl = await inlineRemoteImages(svgText);
1517
+ svgText = _inl.svg;
1269
1518
  const _r = ${importExpr('svgText')};
1270
1519
  const imported = _r.item;
1271
1520
  if (!imported) {
@@ -1275,7 +1524,8 @@ export class PinePaperCodeGenerator {
1275
1524
  imported.scale(${scale});
1276
1525
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
1277
1526
  app.historyManager.saveState();
1278
- return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } };
1527
+ return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
1528
+ ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
1279
1529
  })();
1280
1530
  `.trim();
1281
1531
  }
@@ -1284,18 +1534,23 @@ export class PinePaperCodeGenerator {
1284
1534
  const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1285
1535
  return `
1286
1536
  // Import SVG string${source === 'figma' ? ' (Figma-normalised)' : ''}
1287
- const svgString = \`${escapedSvg}\`;
1537
+ (async function() {
1538
+ let svgString = \`${escapedSvg}\`;
1539
+ ${INLINE_REMOTE_IMAGES}
1540
+ const _inl = await inlineRemoteImages(svgString);
1541
+ svgString = _inl.svg;
1288
1542
  const _r = ${importExpr('svgString')};
1289
1543
  const imported = _r.item;
1290
- if (imported) {
1291
- imported.position = new paper.Point(${position.x}, ${position.y});
1292
- imported.scale(${scale});
1293
- const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
1294
- app.historyManager.saveState();
1295
- ({ success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } });
1296
- } else {
1544
+ if (!imported) {
1297
1545
  throw new Error('Failed to import SVG');
1298
1546
  }
1547
+ imported.position = new paper.Point(${position.x}, ${position.y});
1548
+ imported.scale(${scale});
1549
+ const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
1550
+ app.historyManager.saveState();
1551
+ return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
1552
+ ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
1553
+ })();
1299
1554
  `.trim();
1300
1555
  }
1301
1556
  return `
@@ -1865,7 +2120,25 @@ const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
1865
2120
  const connectorId = connector.data?.registryId || connector.id;
1866
2121
  app.historyManager.saveState();
1867
2122
 
1868
- ({ connectorId, sourceItemId: '${sourceItemId}', targetItemId: '${targetItemId}' });
2123
+ // A REQUESTED id IS NOT NECESSARILY THE id YOU GOT.
2124
+ //
2125
+ // Connector itself honours 'config.id || <generated>', but DiagramSystem.connect
2126
+ // rebuilds the config from an allowlist — routing, lineColor, lineWidth,
2127
+ // headStyle, headSize, boltEnabled — and 'id' is not on it. So a caller's id is
2128
+ // dropped before the Connector sees it and a timestamp id is minted instead,
2129
+ // which then makes update_connector / remove_connector say "not found" for the
2130
+ // id the caller chose. Engine-side, raised with fxtool; until it lands, the
2131
+ // mismatch is REPORTED rather than left for a later call to trip over.
2132
+ ({
2133
+ connectorId,
2134
+ sourceItemId: '${sourceItemId}',
2135
+ targetItemId: '${targetItemId}',
2136
+ ...(${JSON.stringify(id ?? null)} && connectorId !== ${JSON.stringify(id ?? null)}
2137
+ ? { requestedId: ${JSON.stringify(id ?? null)},
2138
+ idHonoured: false,
2139
+ note: 'this studio dropped the id you asked for and minted its own — use connectorId above for update_connector and remove_connector, not the one you passed.' }
2140
+ : {}),
2141
+ });
1869
2142
  `.trim();
1870
2143
  }
1871
2144
  /**
@@ -2217,11 +2490,28 @@ throw new Error('Unknown diagram mode action: ${action}');
2217
2490
  hasImages: false,
2218
2491
  };
2219
2492
 
2220
- // Get canvas size
2221
- if (app.canvasEl) {
2222
- analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height };
2223
- } else if (paper.view) {
2224
- analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height };
2493
+ // THE ARTBOARD, NOT THE BACKING STORE.
2494
+ //
2495
+ // canvasEl.width and paper.view.size are both in device pixels, so a
2496
+ // 1920x1080 artboard was reported as 2233x1472 — measured from production.
2497
+ // That is not even the device pixel ratio (a 2233px store in a 924px box is
2498
+ // 2.4166), so a caller cannot divide it back out. Anything derived from it
2499
+ // is wrong by an unknown factor, and synthetic clicks computed from it miss
2500
+ // in silence. getCanvasSize() is the artboard the caller set.
2501
+ if (typeof app.getCanvasSize === 'function') {
2502
+ const cs = app.getCanvasSize();
2503
+ if (cs && cs.width) analysis.canvasSize = { width: cs.width, height: cs.height };
2504
+ }
2505
+ if (!analysis.canvasSize.width) {
2506
+ if (app.canvasSize && app.canvasSize.width) {
2507
+ analysis.canvasSize = { width: app.canvasSize.width, height: app.canvasSize.height };
2508
+ } else if (app.canvasEl) {
2509
+ // Last resort on a studio with neither: a backing-store size is better
2510
+ // than zero, and it is flagged so nobody treats it as the artboard.
2511
+ analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height, backingStore: true };
2512
+ } else if (paper.view) {
2513
+ analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height, backingStore: true };
2514
+ }
2225
2515
  }
2226
2516
 
2227
2517
  // Count items and analyze
@@ -2261,12 +2551,16 @@ throw new Error('Unknown diagram mode action: ${action}');
2261
2551
 
2262
2552
  // Check relations
2263
2553
  if (app.relationRegistry) {
2264
- const relations = app.relationRegistry.getAll ? app.relationRegistry.getAll() : [];
2265
- analysis.hasRelations = relations.length > 0;
2266
- relations.forEach(rel => {
2267
- if (rel.relationType) relationSet.add(rel.relationType);
2268
- });
2554
+ // getAll() has never existed on the registry, so this branch always took
2555
+ // the empty fallback and every analysis reported NO relations. getStats()
2556
+ // is the real accessor and answers both questions directly:
2557
+ // associationsByType is keyed by relation type.
2558
+ const relStats = typeof app.relationRegistry.getStats === 'function'
2559
+ ? app.relationRegistry.getStats()
2560
+ : null;
2561
+ (Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
2269
2562
  analysis.relationTypes = Array.from(relationSet);
2563
+ analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
2270
2564
  if (analysis.hasRelations) analysis.hasAnimations = true;
2271
2565
  }
2272
2566
 
@@ -2468,7 +2762,7 @@ throw new Error('Unknown diagram mode action: ${action}');
2468
2762
  switch (op.type) {
2469
2763
  case 'create': {
2470
2764
  const pos = op.position || { x: 400, y: 300 };
2471
- const createProps = (op.properties || {});
2765
+ const createProps = withRadiusAxes((op.properties || {}));
2472
2766
  const props = JSON.stringify(createProps);
2473
2767
  let createCode = `
2474
2768
  const item = app.create('${op.itemType}', { position: { x: ${pos.x}, y: ${pos.y} }, ...${props} });
@@ -2796,6 +3090,19 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2796
3090
  const scaled = scale !== undefined && scale !== 1
2797
3091
  ? { width: even(preset.width * scale), height: even(preset.height * scale) }
2798
3092
  : { width: preset.width, height: preset.height };
3093
+ // 'auto' MEANS THE CANVAS, NOT A PRESET THAT HAPPENS TO BE FIRST.
3094
+ //
3095
+ // `platform` defaults to 'auto', 'auto' is not a key in platformPresets,
3096
+ // and the lookup falls through to `web` — so an export that named no
3097
+ // platform silently rendered 800x600. A beta probe measured exactly that:
3098
+ // three exports of a 960x540 canvas all came back 800x600, reported as a
3099
+ // dimension mismatch because from outside it is one. Nothing said the
3100
+ // canvas had been replaced by a preset.
3101
+ //
3102
+ // Resolved in the generated code rather than here, because the canvas size
3103
+ // is only known in the page. The preset stays the fallback for a studio
3104
+ // too old to report one, and `scale` still applies either way.
3105
+ const usesCanvasSize = !platformPresets[platform];
2799
3106
  // Resolve "auto" format to the platform's recommended format
2800
3107
  const exportFormat = (!format || format === 'auto') ? preset.staticFormat : format;
2801
3108
  // AUDIO-ONLY EXPORT, off the visual path entirely.
@@ -2880,7 +3187,35 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2880
3187
  // doubled the frame count and the render time — a surprise the schema used
2881
3188
  // to hide because there was no way to ask for one without the other.
2882
3189
  const settings = ${JSON.stringify(fps !== undefined ? { ...qualitySettings, fps } : qualitySettings)};
2883
- const dimensions = ${JSON.stringify(scaled)};
3190
+ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as a fallback for a
3191
+ // studio too old to report one. Named platforms emit a plain object literal
3192
+ // just below, so what they render stays readable in the code itself.
3193
+ const __presetDims = ${JSON.stringify(scaled)};
3194
+ const __canvasDims = app.canvasSize || (app.view && app.view.viewSize) || null;
3195
+ const __even = (n) => Math.max(2, Math.round((n * ${scale !== undefined ? scale : 1}) / 2) * 2);
3196
+ const dimensions = (__canvasDims && __canvasDims.width && __canvasDims.height)
3197
+ ? { width: __even(__canvasDims.width), height: __even(__canvasDims.height) }
3198
+ : __presetDims;` : ` const dimensions = ${JSON.stringify(scaled)};`}
3199
+
3200
+ // WHAT THIS SCENE LOSES TO THIS FORMAT, asked at the moment you would care.
3201
+ //
3202
+ // exportFidelity walks the actual scene: a PNG of an animated scene is one
3203
+ // frame, a GIF with a soundtrack drops it, a wav of a silent scene is
3204
+ // silence. The engine derived it so that 117 tool descriptions would not
3205
+ // have to answer "does this survive export?" by hand and drift.
3206
+ //
3207
+ // Reported ONLY when there is something to report. An empty list would read
3208
+ // as "this format is lossless", and it does not mean that — it means this
3209
+ // scene has nothing THIS format will lose. Omitting it entirely is the one
3210
+ // shape that cannot be misread, and it keeps a clean export quiet.
3211
+ function fidelity(fmt) {
3212
+ try {
3213
+ if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') return {};
3214
+ const r = app.exportEngine.exportFidelity(fmt);
3215
+ if (!r || !r.warnings || r.warnings.length === 0) return {};
3216
+ return { fidelity: { warnings: r.warnings, checked: r.checked } };
3217
+ } catch (e) { return {}; }
3218
+ }
2884
3219
 
2885
3220
  // Preflight: same resolved settings as the real export, but render nothing.
2886
3221
  // Branching HERE rather than in a separate tool is deliberate — the estimate
@@ -2906,6 +3241,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2906
3241
  fps: settings.fps,
2907
3242
  dimensions: dimensions.width + 'x' + dimensions.height,
2908
3243
  estimate: est,
3244
+ ...fidelity(format),
2909
3245
  };
2910
3246
  }
2911
3247
 
@@ -2932,13 +3268,38 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2932
3268
  if (!params || (params.mode || 'keyframes') !== 'keyframes' || !params.keyframes || !params.keyframes.length) {
2933
3269
  return { success: false, platform, format, framing, error: 'framing: "camera" requires a keyframe-mode camera walkthrough with at least one keyframe.' };
2934
3270
  }
2935
- const firstKf = params.keyframes[0];
2936
- const firstZoom = firstKf && firstKf.zoom > 0 ? firstKf.zoom : 1;
2937
- const canvasEl = document.querySelector('canvas');
2938
- cameraDims = {
2939
- width: Math.round((canvasEl ? canvasEl.width : dimensions.width) / firstZoom),
2940
- height: Math.round((canvasEl ? canvasEl.height : dimensions.height) / firstZoom),
2941
- };
3271
+ // MATCH THE ENGINE'S FRAME EXACTLY, AND DO NOT SECOND-GUESS THE ZOOM.
3272
+ //
3273
+ // This divided canvasEl.width — device pixels — so a camera export came
3274
+ // out 2234x1472 whatever the board was. That is fixed by reading the
3275
+ // artboard, and the chain below is deliberately the same one the engine's
3276
+ // own _exportFrameSize() uses: getCanvasSize(), then app.canvasSize, then
3277
+ // a known frame. Two places computing one number is how they drift.
3278
+ //
3279
+ // The zoom divide is gone too, and that is the part worth explaining.
3280
+ // VideoExporter._frameCropRect returns the WHOLE canvas element whenever a
3281
+ // camera animation exists — the camera owns the view, so it captures
3282
+ // everything the camera frames. Dividing by the first keyframe's zoom
3283
+ // therefore never reframed anything; it only shrank the output, so a
3284
+ // zoom-2 export produced a half-size video of the same content. Asking for
3285
+ // the artboard's own size is both simpler and right.
3286
+ //
3287
+ // NOTE: the engine currently scales that whole-canvas crop into the output
3288
+ // frame without preserving aspect, so the picture is squashed by the ratio
3289
+ // between the workspace canvas and the board (measured: 1.17 landscape,
3290
+ // 0.37 portrait). That is VideoExporter._frameCropRect, not this — raised
3291
+ // with fxtool-f2. Matching frames here means that when it is fixed, these
3292
+ // two cannot disagree about the aspect on top of it.
3293
+ let camBase = null;
3294
+ if (typeof app.getCanvasSize === 'function') {
3295
+ const cs = app.getCanvasSize();
3296
+ if (cs && cs.width > 0) camBase = { width: cs.width, height: cs.height };
3297
+ }
3298
+ if (!camBase && app.canvasSize && app.canvasSize.width > 0) {
3299
+ camBase = { width: app.canvasSize.width, height: app.canvasSize.height };
3300
+ }
3301
+ if (!camBase) camBase = { width: dimensions.width, height: dimensions.height };
3302
+ cameraDims = { width: camBase.width, height: camBase.height };
2942
3303
  }
2943
3304
 
2944
3305
  let result = { success: false, platform, format, quality, framing };
@@ -2960,14 +3321,35 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2960
3321
 
2961
3322
  case 'png':
2962
3323
  if (app.exportEngine && app.exportEngine.exportPNG) {
2963
- const dataUrl = await app.exportEngine.exportPNG({ dpi: settings.dpi });
3324
+ // PNG IGNORED THE PLATFORM ENTIRELY.
3325
+ //
3326
+ // exportPNG takes dpi or scale and no dimensions, so passing only
3327
+ // dpi scaled the CANVAS by dpi/96 and the preset was never applied:
3328
+ // platform 'youtube-thumbnail' (1280x720) and platform 'auto' on a
3329
+ // 1920x1080 board both returned 3000x1688, which is 1920 x 150/96.
3330
+ // Third member of the same family as the camera dims and analyze —
3331
+ // a pixel count that came from the rendering scale rather than from
3332
+ // what the caller asked for.
3333
+ //
3334
+ // dimensions is already resolved: the preset for a named platform,
3335
+ // the board itself for auto. Turning it into a scale is the only
3336
+ // lever exportPNG offers, so that is what it gets.
3337
+ const pngBoard = (typeof app.getCanvasSize === 'function' && app.getCanvasSize())
3338
+ || app.canvasSize
3339
+ || { width: dimensions.width, height: dimensions.height };
3340
+ const pngScale = pngBoard.width > 0 ? dimensions.width / pngBoard.width : 1;
3341
+ const png = await app.exportEngine.exportPNG({ scale: pngScale });
3342
+ const dataUrl = (png && png.dataUrl) ? png.dataUrl : png;
2964
3343
  result = {
2965
3344
  success: true,
2966
3345
  platform,
2967
3346
  format: 'png',
2968
3347
  data: dataUrl,
2969
3348
  mimeType: 'image/png',
2970
- size: Math.round(dataUrl.length * 0.75)
3349
+ size: Math.round(String(dataUrl).length * 0.75),
3350
+ dimensions: (png && png.width)
3351
+ ? { width: png.width, height: png.height }
3352
+ : { width: dimensions.width, height: dimensions.height },
2971
3353
  };
2972
3354
  } else {
2973
3355
  const canvas = document.querySelector('canvas');
@@ -3135,6 +3517,11 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3135
3517
  result = { success: false, error: e.message, platform, format };
3136
3518
  }
3137
3519
 
3520
+ // One site, after every branch has set its result: a successful export says
3521
+ // what this scene lost to this format, rather than the caller finding out by
3522
+ // opening the file. Only when there is something to say — see fidelity().
3523
+ if (result && result.success) Object.assign(result, fidelity(format));
3524
+
3138
3525
  return result;
3139
3526
  })();
3140
3527
  `.trim();
@@ -3161,11 +3548,28 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3161
3548
  hasImages: false,
3162
3549
  };
3163
3550
 
3164
- // Get canvas size
3165
- if (app.canvasEl) {
3166
- analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height };
3167
- } else if (paper.view) {
3168
- analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height };
3551
+ // THE ARTBOARD, NOT THE BACKING STORE.
3552
+ //
3553
+ // canvasEl.width and paper.view.size are both in device pixels, so a
3554
+ // 1920x1080 artboard was reported as 2233x1472 — measured from production.
3555
+ // That is not even the device pixel ratio (a 2233px store in a 924px box is
3556
+ // 2.4166), so a caller cannot divide it back out. Anything derived from it
3557
+ // is wrong by an unknown factor, and synthetic clicks computed from it miss
3558
+ // in silence. getCanvasSize() is the artboard the caller set.
3559
+ if (typeof app.getCanvasSize === 'function') {
3560
+ const cs = app.getCanvasSize();
3561
+ if (cs && cs.width) analysis.canvasSize = { width: cs.width, height: cs.height };
3562
+ }
3563
+ if (!analysis.canvasSize.width) {
3564
+ if (app.canvasSize && app.canvasSize.width) {
3565
+ analysis.canvasSize = { width: app.canvasSize.width, height: app.canvasSize.height };
3566
+ } else if (app.canvasEl) {
3567
+ // Last resort on a studio with neither: a backing-store size is better
3568
+ // than zero, and it is flagged so nobody treats it as the artboard.
3569
+ analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height, backingStore: true };
3570
+ } else if (paper.view) {
3571
+ analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height, backingStore: true };
3572
+ }
3169
3573
  }
3170
3574
 
3171
3575
  // Count items and analyze types
@@ -3212,12 +3616,16 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3212
3616
 
3213
3617
  // Check relations
3214
3618
  if (app.relationRegistry) {
3215
- const relations = app.relationRegistry.getAll ? app.relationRegistry.getAll() : [];
3216
- analysis.hasRelations = relations.length > 0;
3217
- relations.forEach(rel => {
3218
- if (rel.relationType) relationSet.add(rel.relationType);
3219
- });
3619
+ // getAll() has never existed on the registry, so this branch always took
3620
+ // the empty fallback and every analysis reported NO relations. getStats()
3621
+ // is the real accessor and answers both questions directly:
3622
+ // associationsByType is keyed by relation type.
3623
+ const relStats = typeof app.relationRegistry.getStats === 'function'
3624
+ ? app.relationRegistry.getStats()
3625
+ : null;
3626
+ (Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
3220
3627
  analysis.relationTypes = Array.from(relationSet);
3628
+ analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
3221
3629
  if (analysis.hasRelations) analysis.hasAnimations = true;
3222
3630
  }
3223
3631
 
@@ -3372,8 +3780,59 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3372
3780
  */
3373
3781
  generateLoadMap(input) {
3374
3782
  const validated = LoadMapInputSchema.parse(input);
3375
- const { mapId, ...mapOptions } = validated;
3376
- const optionsStr = Object.keys(mapOptions).length > 0 ? JSON.stringify(mapOptions) : '{}';
3783
+ const { mapId, ...raw } = validated;
3784
+ // THIS SCHEMA AND loadMap() SHARED ALMOST NO VOCABULARY.
3785
+ //
3786
+ // loadMap(source, options) destructures projection, center, scale, rotate,
3787
+ // fitBounds, parent, styles, style, interactive, selectable, showOcean,
3788
+ // oceanColor, quality, smoothPaths and simplifyTolerance. Of the twelve
3789
+ // options this tool offered, SEVEN were none of those: the styling keys
3790
+ // live nested under `styles` and are spelled differently there, and the two
3791
+ // interactivity flags have other names entirely. Every one was accepted,
3792
+ // JSON-stringified into the call and dropped — a caller who set fillColor
3793
+ // got the default grey with no indication why.
3794
+ const { fillColor, strokeColor, strokeWidth, hoverFill, hoverStroke, enableHover, enableClick, options: extraOptions, ...passthrough } = raw;
3795
+ // The engine's own style vocabulary — see stylePresets in MapSystem.js.
3796
+ const styles = {};
3797
+ if (fillColor !== undefined)
3798
+ styles.fill = fillColor;
3799
+ if (strokeColor !== undefined)
3800
+ styles.stroke = strokeColor;
3801
+ // strokeWidth only survives inside `styles`; the merge overwrites a
3802
+ // top-level one with the quality preset's value.
3803
+ if (strokeWidth !== undefined)
3804
+ styles.strokeWidth = strokeWidth;
3805
+ if (hoverFill !== undefined)
3806
+ styles.hoverFill = hoverFill;
3807
+ // hoverStroke reads from mergedStyles beside hoverFill, so it belongs in
3808
+ // the same place. It landed late — the two MCP specs documented it for a
3809
+ // long time before any source file did — and an older studio merges it,
3810
+ // finds nothing that reads it, and leaves the outline at the base stroke.
3811
+ // Mapped rather than stripped so it simply starts working on such a studio
3812
+ // without a change here, and reported below so a caller on an older one
3813
+ // learns why their outline did not move.
3814
+ if (hoverStroke !== undefined)
3815
+ styles.hoverStroke = hoverStroke;
3816
+ // `options` was declared by the tool's description and stripped by the
3817
+ // schema, so `{style: 'dark'}` never reached loadMap. It carries the
3818
+ // options not named individually above — style presets, showOcean,
3819
+ // fitBounds, preserve — and is spread FIRST so an explicitly named field
3820
+ // still wins over the same key inside it.
3821
+ const engineOptions = {
3822
+ ...(extraOptions && typeof extraOptions === 'object' ? extraOptions : {}),
3823
+ ...passthrough,
3824
+ };
3825
+ if (Object.keys(styles).length > 0)
3826
+ engineOptions.styles = styles;
3827
+ if (enableHover !== undefined)
3828
+ engineOptions.interactive = enableHover;
3829
+ if (enableClick !== undefined)
3830
+ engineOptions.selectable = enableClick;
3831
+ const ignored = hoverStroke !== undefined ? ['hoverStroke'] : [];
3832
+ const optionsStr = Object.keys(engineOptions).length > 0 ? JSON.stringify(engineOptions) : '{}';
3833
+ const warn = ignored.length
3834
+ ? `\n conditional: ${JSON.stringify(ignored)},\n note: 'hoverStroke needs a studio recent enough to read it; an older one ignores it and the hover outline stays the base stroke.',`
3835
+ : '';
3377
3836
  return `
3378
3837
  // Load geographic map
3379
3838
  (async function() {
@@ -3388,7 +3847,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3388
3847
  mapId: result.mapId || '${mapId}',
3389
3848
  regions: result.regions?.length || 0,
3390
3849
  bounds: result.bounds,
3391
- center: result.center
3850
+ center: result.center,${warn}
3392
3851
  };
3393
3852
  } catch (error) {
3394
3853
  return { success: false, error: error.message };
@@ -3402,7 +3861,28 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3402
3861
  generateHighlightRegions(input) {
3403
3862
  const validated = HighlightRegionsInputSchema.parse(input);
3404
3863
  const regionIds = JSON.stringify(validated.regionIds);
3405
- const optionsStr = validated.options ? JSON.stringify(validated.options) : '{}';
3864
+ // highlightRegions(ids, style) reads style.fill / .stroke / .strokeWidth.
3865
+ // This passed fillColor / strokeColor / strokeWidth, so the first two were
3866
+ // dropped and every highlight came out the default blue. A caller reaching
3867
+ // for `color` — the obvious name, and the one a pilot actually used — was
3868
+ // rejected by the schema before it got this far; it is accepted now and
3869
+ // means the fill.
3870
+ const ho = (validated.options ?? {});
3871
+ const style = {};
3872
+ const fill = ho.color ?? ho.fillColor ?? ho.fill;
3873
+ if (fill !== undefined)
3874
+ style.fill = fill;
3875
+ const stroke = ho.strokeColor ?? ho.stroke;
3876
+ if (stroke !== undefined)
3877
+ style.stroke = stroke;
3878
+ if (ho.strokeWidth !== undefined)
3879
+ style.strokeWidth = ho.strokeWidth;
3880
+ for (const [k, v] of Object.entries(ho)) {
3881
+ if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth'].includes(k) && v !== undefined) {
3882
+ style[k] = v;
3883
+ }
3884
+ }
3885
+ const optionsStr = JSON.stringify(style);
3406
3886
  return `
3407
3887
  // Highlight map regions
3408
3888
  (function() {
@@ -3447,7 +3927,45 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3447
3927
  generateApplyDataColors(input) {
3448
3928
  const validated = ApplyDataColorsInputSchema.parse(input);
3449
3929
  const dataStr = JSON.stringify(validated.data);
3450
- const optionsStr = validated.options ? JSON.stringify(validated.options) : '{}';
3930
+ // THE ENGINE TAKES A COLOUR RAMP, NOT THE NAME OF ONE.
3931
+ //
3932
+ // applyDataColors destructures `colorScale = ['#f7fbff', '#08519c']` — two
3933
+ // endpoints it interpolates between — plus `domain` and `legend`. This tool
3934
+ // published a preset NAME ('greens'), which the engine then indexed as an
3935
+ // array: colorScale[0] of a string is 'g', and every region came out
3936
+ // near-black. It also published minValue/maxValue/showLegend, none of which
3937
+ // the engine reads.
3938
+ //
3939
+ // Translated here rather than in the schema: the names are the friendlier
3940
+ // surface and this is ordinary cross-repo vocabulary drift, which is what
3941
+ // the emitter exists to absorb.
3942
+ const RAMPS = {
3943
+ blues: ['#f7fbff', '#08519c'],
3944
+ greens: ['#f7fcf5', '#006d2c'],
3945
+ reds: ['#fff5f0', '#a50f15'],
3946
+ oranges: ['#fff5eb', '#a63603'],
3947
+ purples: ['#fcfbfd', '#54278f'],
3948
+ heat: ['#ffffb2', '#bd0026'],
3949
+ };
3950
+ const o = validated.options ?? {};
3951
+ const engineOptions = {};
3952
+ if (o.colorScale)
3953
+ engineOptions.colorScale = RAMPS[o.colorScale] ?? RAMPS.blues;
3954
+ if (o.minValue !== undefined || o.maxValue !== undefined) {
3955
+ const values = Object.values(validated.data);
3956
+ engineOptions.domain = [
3957
+ o.minValue ?? Math.min(...values),
3958
+ o.maxValue ?? Math.max(...values),
3959
+ ];
3960
+ }
3961
+ if (o.showLegend !== undefined)
3962
+ engineOptions.legend = o.showLegend;
3963
+ for (const [k, v] of Object.entries(o)) {
3964
+ if (!['colorScale', 'minValue', 'maxValue', 'showLegend'].includes(k) && v !== undefined) {
3965
+ engineOptions[k] = v;
3966
+ }
3967
+ }
3968
+ const optionsStr = JSON.stringify(engineOptions);
3451
3969
  return `
3452
3970
  // Apply choropleth data colors
3453
3971
  (function() {
@@ -3514,8 +4032,18 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3514
4032
  }
3515
4033
 
3516
4034
  try {
3517
- app.mapSystem.addLabels({ regions: ${regionsStr}, ...${optionsStr} });
3518
- return { success: true };
4035
+ // addLabels() has never existed. addRegionLabel(regionId, options) is the
4036
+ // engine's own per-region call, and this tool's input is a list of regions,
4037
+ // so the loop IS the mapping — applyCustomLabels takes a
4038
+ // {regionId: text} map instead, which is a different question.
4039
+ const _labelled = [];
4040
+ const _missed = [];
4041
+ for (const _r of ${regionsStr}) {
4042
+ const _item = app.mapSystem.addRegionLabel(_r, ${optionsStr});
4043
+ (_item ? _labelled : _missed).push(_r);
4044
+ }
4045
+ return { success: _labelled.length > 0, labelled: _labelled, count: _labelled.length,
4046
+ ...(_missed.length ? { notLabelled: _missed, note: 'no region on this map matched these ids — check get_highlighted_map_regions or the map you loaded.' } : {}) };
3519
4047
  } catch (error) {
3520
4048
  return { success: false, error: error.message };
3521
4049
  }
@@ -3540,8 +4068,13 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3540
4068
  }
3541
4069
 
3542
4070
  try {
3543
- app.mapSystem.panTo(${validated.lat}, ${validated.lon}${Object.keys(options).length > 0 ? ', ' + JSON.stringify(options) : ''});
3544
- return { success: true, panTo: [${validated.lat}, ${validated.lon}] };
4071
+ // panTo() has never existed on any studio, and there is no lat/lon pan for
4072
+ // a flat map to route this to. Refused by NAME with the calls that do work,
4073
+ // rather than letting the caller take an undefined-is-not-a-function.
4074
+ return { success: false, error: 'pan_map is not supported: the engine has no lat/lon pan. '
4075
+ + 'On a globe, rotateGlobeTo brings a coordinate to the front; on a flat map, zoom to a REGION '
4076
+ + '(zoomToRegion) or call resetView for the whole view. This tool accepted coordinates and did '
4077
+ + 'nothing for as long as it existed.' };
3545
4078
  } catch (error) {
3546
4079
  return { success: false, error: error.message };
3547
4080
  }
@@ -3566,8 +4099,11 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3566
4099
  }
3567
4100
 
3568
4101
  try {
3569
- app.mapSystem.zoomTo(${validated.level}${Object.keys(options).length > 0 ? ', ' + JSON.stringify(options) : ''});
3570
- return { success: true, zoomLevel: ${validated.level} };
4102
+ // zoomTo(level) has never existed. The engine zooms to a REGION, not to a
4103
+ // numeric level, so there is nothing to convert a level into.
4104
+ return { success: false, error: 'zoom_map takes a numeric level and the engine has no such call: it zooms to a REGION. '
4105
+ + 'Use zoomToRegion with a region id, or resetView for the full view. A level was accepted here and did '
4106
+ + 'nothing for as long as this tool existed.' };
3571
4107
  } catch (error) {
3572
4108
  return { success: false, error: error.message };
3573
4109
  }
@@ -3586,8 +4122,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3586
4122
  }
3587
4123
 
3588
4124
  try {
3589
- const mapData = app.mapSystem.exportMap();
3590
- return { success: true, ...mapData };
4125
+ // exportMap() has never existed. exportGeoJSON() is the real one, and it
4126
+ // returns the geometry rather than a "configuration" — named accordingly
4127
+ // instead of being spread into a shape the caller cannot rely on.
4128
+ if (typeof app.mapSystem.exportGeoJSON !== 'function') {
4129
+ return { success: false, error: 'this studio cannot export map data — app.mapSystem.exportGeoJSON is unavailable.' };
4130
+ }
4131
+ const geojson = app.mapSystem.exportGeoJSON();
4132
+ const info = typeof app.mapSystem.getMapSourceInfo === 'function' ? app.mapSystem.getMapSourceInfo() : null;
4133
+ return { success: true, geojson, ...(info ? { source: info } : {}) };
3591
4134
  } catch (error) {
3592
4135
  return { success: false, error: error.message };
3593
4136
  }
@@ -3654,7 +4197,16 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3654
4197
  }
3655
4198
 
3656
4199
  try {
3657
- const region = app.mapSystem.getRegionAtPoint({ x: ${validated.x}, y: ${validated.y} });
4200
+ // getRegionAtPoint() has never existed; the engine exposes no hit test.
4201
+ // canvasToGeo turns a point into COORDINATES, which is a different answer,
4202
+ // so it is offered rather than quietly substituted.
4203
+ if (typeof app.mapSystem.canvasToGeo === 'function') {
4204
+ const coord = app.mapSystem.canvasToGeo(${validated.x}, ${validated.y});
4205
+ return { success: false, coordinate: coord,
4206
+ error: 'the engine has no region hit test. That point is at ' + JSON.stringify(coord)
4207
+ + ' — find regions by id with get_highlighted_map_regions or getSelectedRegions.' };
4208
+ }
4209
+ const region = null;
3658
4210
  if (region) {
3659
4211
  return { success: true, regionId: region.id, regionName: region.name, properties: region.properties };
3660
4212
  }
@@ -3708,7 +4260,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3708
4260
  }
3709
4261
 
3710
4262
  try {
3711
- const result = app.mapSystem.animateWave({
4263
+ const result = app.mapSystem.animateRegionsWave({
3712
4264
  duration: ${validated.duration || 10},
3713
4265
  loop: ${validated.loop !== false},
3714
4266
  colors: ${colorsStr},
@@ -4406,6 +4958,17 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4406
4958
  const keyframesStr = validated.keyframes ? JSON.stringify(validated.keyframes) : 'null';
4407
4959
  const maskOptionsStr = validated.maskOptions ? JSON.stringify(validated.maskOptions) : '{}';
4408
4960
  const preset = validated.preset || validated.maskType || 'wipeLeft';
4961
+ // MODE 3 PASSED AN ANIMATION PRESET WHERE A MASK SHAPE GOES.
4962
+ //
4963
+ // applyCustomMask(item, maskType, keyframes, options) wants a SHAPE —
4964
+ // 'rectangle', 'circle', 'star'. With preset + keyframes this handed it
4965
+ // 'iris' or 'wipeLeft', which is an animation name, so the shape was
4966
+ // whatever the engine falls back to and the keyframes drove the wrong
4967
+ // geometry. maskType is the field that carries the shape, and it is only
4968
+ // used as the preset above when no preset was given at all.
4969
+ const customShape = validated.maskType && validated.maskType !== preset
4970
+ ? validated.maskType
4971
+ : 'rectangle';
4409
4972
  // Per-preset options. Custom keyframes are an animated-mask extension —
4410
4973
  // when supplied, fall through to maskingSystem.applyCustomMask instead.
4411
4974
  return `
@@ -4421,7 +4984,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4421
4984
  const mergedOpts = Object.assign({}, ${optionsStr}, ${maskOptionsStr});
4422
4985
  let maskedGroup;
4423
4986
  if (${keyframesStr} && app.maskingSystem && app.maskingSystem.applyCustomMask) {
4424
- maskedGroup = app.maskingSystem.applyCustomMask(item, '${preset}', ${keyframesStr}, mergedOpts);
4987
+ maskedGroup = app.maskingSystem.applyCustomMask(item, '${customShape}', ${keyframesStr}, mergedOpts);
4425
4988
  } else {
4426
4989
  maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
4427
4990
  }
@@ -4641,7 +5204,51 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4641
5204
  }
4642
5205
  try {
4643
5206
  await app.templateManager.loadTemplate('${templateId}', true);
4644
- return { success: true, templateId: '${templateId}', message: 'Template applied successfully. Canvas has been replaced with template content.' };
5207
+
5208
+ // DID THE CLIPPED CHARACTER PARTS SURVIVE?
5209
+ //
5210
+ // A clipped character group is saved with its nested texture and mask in
5211
+ // paperJSON. A studio whose restore predates the reader for that key
5212
+ // rebuilds it through the generic group branch instead — an empty,
5213
+ // unclipped group — so the part is written to the template correctly and
5214
+ // dropped on the way back in, with nothing raised.
5215
+ //
5216
+ // Checked by BEHAVIOUR, not by version: the template says how many clipped
5217
+ // groups it carries, the canvas says how many came back. An engine that
5218
+ // restores them makes this condition unreachable, so it needs no removing
5219
+ // later; an older studio is exactly where it still matters, and this
5220
+ // package ships independently of the studio it drives.
5221
+ //
5222
+ // THE WARNING MATTERS MORE THAN THE LOAD. Loading is harmless and
5223
+ // reversible — the file on disk still holds the paperJSON, so the same
5224
+ // template reloads intact on a studio that can read it. Re-SAVING from
5225
+ // this canvas is not: the exporter would serialise the empty group it can
5226
+ // see, and the texture and mask would leave the file for good.
5227
+ let fidelity = {};
5228
+ try {
5229
+ const tpl = (app.templateManager.getAllTemplates() || [])
5230
+ .find(function(t) { return t && (t.id === '${templateId}' || t.templateId === '${templateId}'); });
5231
+ const items = tpl && tpl.data && Array.isArray(tpl.data.items) ? tpl.data.items : null;
5232
+ const declared = items ? items.filter(function(i) { return i && i.type === 'group' && i.paperJSON; }).length : 0;
5233
+ if (declared > 0 && app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
5234
+ const clipped = (app.itemRegistry.getAll() || []).filter(function(e) {
5235
+ const it = e && (e.item || e);
5236
+ return it && it.className === 'Group' && it.clipped === true;
5237
+ }).length;
5238
+ if (clipped === 0) {
5239
+ fidelity = {
5240
+ clippedPartsLost: declared,
5241
+ warning: 'This template carries ' + declared + ' clipped character part(s) with a nested texture and mask, '
5242
+ + 'and this studio restored none of them — they are on the canvas as empty groups. '
5243
+ + 'DO NOT SAVE THIS CANVAS BACK OVER THE TEMPLATE: the export would write the empty groups and the '
5244
+ + 'texture would be lost from the file permanently. The template itself is still intact; update the '
5245
+ + 'studio and load it again.',
5246
+ };
5247
+ }
5248
+ }
5249
+ } catch (e) { /* a check that cannot run must not fail the load */ }
5250
+
5251
+ return { success: true, templateId: '${templateId}', message: 'Template applied successfully. Canvas has been replaced with template content.', ...fidelity };
4645
5252
  } catch (e) {
4646
5253
  return { error: 'Failed to apply template: ' + e.message };
4647
5254
  }
@@ -4681,13 +5288,25 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4681
5288
  // the network again — so a host that allows fetch but blocks the image
4682
5289
  // load (CORS on <img>, a tainted-canvas refusal) now works too.
4683
5290
  let src = '${url}';
5291
+ // A STAGED KEY, not the bytes. The handler writes anything over ~64KB to
5292
+ // window.__ppStage and passes the key instead, because the governor's
5293
+ // loop-guard transform bails on a code string that large and takes the
5294
+ // run's return value with it. See ExecuteCodeOptions.stage.
5295
+ if (src.indexOf('__ppStage:') === 0) {
5296
+ const _key = src.slice('__ppStage:'.length);
5297
+ const _staged = window.__ppStage && window.__ppStage[_key];
5298
+ if (!_staged) {
5299
+ return { success: false, error: 'the image bytes were staged as ' + _key + ' and are not on the page — this is a bug in the MCP server, not your call.' };
5300
+ }
5301
+ src = _staged;
5302
+ }
4684
5303
  if (!src.startsWith('data:')) {
4685
5304
  let res;
4686
5305
  try {
4687
5306
  res = await fetch(src);
4688
5307
  } catch (netErr) {
4689
5308
  return { success: false, error: 'could not reach ' + src + ' — ' + (netErr && netErr.message ? netErr.message : 'network request failed')
4690
- + '. This is the browser\'s network stack: check the host resolves, the port is open, and that any proxy passes plain HTTP as well as HTTPS CONNECT.' };
5309
+ + '. The failure is in the browser network stack: check the host resolves, the port is open, and that any proxy passes plain HTTP as well as HTTPS CONNECT.' };
4691
5310
  }
4692
5311
  if (!res.ok) {
4693
5312
  return { success: false, error: 'the server refused ' + src + ' — HTTP ' + res.status + ' ' + (res.statusText || ''), status: res.status };
@@ -5537,7 +6156,7 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
5537
6156
  // Get history state
5538
6157
  (function() {
5539
6158
  ${guard}
5540
- const state = app.historyManager.getState();
6159
+ const state = app.historyManager.getInfo();
5541
6160
  return { success: true, action: 'get_state', ...state };
5542
6161
  })();`.trim();
5543
6162
  default:
@@ -5614,38 +6233,45 @@ ${resolve}
5614
6233
  }
5615
6234
  }
5616
6235
  generateLasso(input) {
5617
- const guard = `if (!app.imageTools) return { error: 'ImageTools not available' };`;
6236
+ // LASSO IS A MOUSE TOOL. IT HAS NO HEADLESS FORM.
6237
+ //
6238
+ // These called app.imageTools.activateLasso and .applyLasso, neither of
6239
+ // which exists — imageTools is an ImageToolsManager and the lasso lives on
6240
+ // app.lassoTool. `activate` is real there, but the extraction is driven by
6241
+ // startStroke / continueStroke / endStroke: a freehand path the USER draws.
6242
+ // There is no applyLasso to finish it, so wiring activate alone would leave
6243
+ // a caller in a mode it cannot get out of or complete.
6244
+ //
6245
+ // Refused by name, with the tools that do the same job without a pointer.
6246
+ const why = 'the lasso is an interactive mouse tool (app.lassoTool: activate, then startStroke/continueStroke/endStroke as the user draws). '
6247
+ + 'There is no headless call that completes an extraction, so this tool could never work over MCP. '
6248
+ + 'To cut a region without a pointer, use pinepaper_extract_object (detects and extracts by description), '
6249
+ + 'or pinepaper_cutout_style / the mask actions on pinepaper_image_tools to shape an existing raster.';
5618
6250
  switch (input.action) {
5619
6251
  case 'activate':
5620
- return `
5621
- // Activate lasso selection
5622
- (async function() {
5623
- ${guard}
5624
- try {
5625
- await app.imageTools.activateLasso(${JSON.stringify(input.itemId || '')});
5626
- return { success: true, action: 'activate', itemId: ${JSON.stringify(input.itemId || '')} };
5627
- } catch (e) {
5628
- return { error: 'Failed to activate lasso: ' + e.message };
5629
- }
5630
- })();`.trim();
5631
6252
  case 'apply':
5632
6253
  return `
5633
- // Apply lasso selection
5634
- (async function() {
5635
- ${guard}
5636
- try {
5637
- const result = await app.imageTools.applyLasso();
5638
- return { success: true, action: 'apply' };
5639
- } catch (e) {
5640
- return { error: 'Failed to apply lasso: ' + e.message };
5641
- }
5642
- })();`.trim();
6254
+ // Lasso — refused, rather than calling into a method that does not exist
6255
+ ({ success: false, action: ${JSON.stringify(input.action)}, error: ${JSON.stringify(why)} });`.trim();
5643
6256
  default:
5644
6257
  return `(function() { return { error: 'Unknown lasso action: ${input.action}' }; })();`;
5645
6258
  }
5646
6259
  }
5647
6260
  generateCutoutStyle(input) {
5648
- const guard = `if (!app.imageTools) return { error: 'ImageTools not available' };`;
6261
+ // THE FACADE WAS THE WRONG ONE ENTIRELY.
6262
+ //
6263
+ // These called app.imageTools.applyCutoutStyle / .getCutoutStyles, and
6264
+ // neither exists anywhere in the engine — imageTools is an
6265
+ // ImageToolsManager and knows nothing about cutout presets. Reported from
6266
+ // production as "apply returns success:true with the error nested inside",
6267
+ // which is what awaiting a call on undefined inside a try looks like.
6268
+ //
6269
+ // The real surface is app.cutoutStyles (a CutoutStylePresets), with
6270
+ // applyPreset(item, name, options) and getPresets(). The parity guard
6271
+ // could not see this: readFacades resolved a class by CONVENTION —
6272
+ // imageTools -> ImageTools, which is not the class's name — so the facade
6273
+ // was never mapped, and an unmapped facade is not checked.
6274
+ const guard = `if (!app.cutoutStyles) return { success: false, error: 'cutout styles are unavailable on this studio (app.cutoutStyles is absent) — update PinePaper Studio.' };`;
5649
6275
  switch (input.action) {
5650
6276
  case 'apply':
5651
6277
  return `
@@ -5653,7 +6279,11 @@ ${resolve}
5653
6279
  (async function() {
5654
6280
  ${guard}
5655
6281
  try {
5656
- const result = await app.imageTools.applyCutoutStyle(${JSON.stringify(input.itemId || '')}, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
6282
+ const target = app.getItemById && app.getItemById(${JSON.stringify(input.itemId || '')});
6283
+ if (!target) {
6284
+ return { success: false, action: 'apply', error: 'no item ' + ${JSON.stringify(input.itemId || '')} + ' — applyPreset takes a live item, so the id has to resolve first.' };
6285
+ }
6286
+ const result = await app.cutoutStyles.applyPreset(target, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
5657
6287
  // An unknown preset returns the very item it was given, unchanged — so a
5658
6288
  // truthy result proves nothing. The engine records the refusal on it.
5659
6289
  const cutRej = result && result.data && result.data.cutoutStyleRejected;
@@ -5670,7 +6300,9 @@ ${resolve}
5670
6300
  // List available cutout styles
5671
6301
  (function() {
5672
6302
  ${guard}
5673
- const styles = app.imageTools.getCutoutStyles();
6303
+ const styles = typeof app.getCutoutStylePresets === 'function'
6304
+ ? app.getCutoutStylePresets()
6305
+ : app.cutoutStyles.getPresets();
5674
6306
  return { success: true, action: 'list', styles };
5675
6307
  })();`.trim();
5676
6308
  default:
@@ -5711,14 +6343,25 @@ ${resolve}
5711
6343
 
5712
6344
  const precomp = app.createPrecomp(items, opts);
5713
6345
  if (!precomp) return { success: false, error: 'createPrecomp returned nothing' };
5714
- return { success: true, action: 'create', precompId: precomp?.id || precomp, itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
6346
+ // STRINGIFIED, because the schema declares precompId as a string and the
6347
+ // engine mints a NUMBER. Returning the number meant the id could not be
6348
+ // round-tripped: pass it back to add/remove and zod rejects it before the
6349
+ // call is made.
6350
+ const _pid = precomp && precomp.id !== undefined ? precomp.id : precomp;
6351
+ return { success: true, action: 'create', precompId: String(_pid), itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
5715
6352
  })();`.trim();
5716
6353
  }
5717
6354
  case 'add':
5718
6355
  return `
5719
6356
  // Add item to precomp
5720
6357
  (function() {
5721
- app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
6358
+ // addToPrecomp answers falsy when the precomp or the item is not found, and
6359
+ // this reported success regardless — the same silent-success shape as an
6360
+ // unknown itemId elsewhere.
6361
+ const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
6362
+ if (_ok === false || _ok === null || _ok === undefined) {
6363
+ return { success: false, action: 'add', error: 'nothing was added — check the precompId (create returns it as a string) and the itemId with pinepaper_get_items.' };
6364
+ }
5722
6365
  return { success: true, action: 'add', precompId: ${JSON.stringify(input.precompId || '')}, itemId: ${JSON.stringify(input.itemId || '')} };
5723
6366
  })();`.trim();
5724
6367
  case 'remove':
@@ -6237,7 +6880,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
6237
6880
  // Trigger interaction action
6238
6881
  (function() {
6239
6882
  ${guard}
6240
- app.interactionSystem.triggerAction('${input.actionType}', ${params});
6883
+ app.interactionSystem.executeAction('${input.actionType}', ${params});
6241
6884
  return { success: true, action: 'trigger_action', actionType: '${input.actionType}' };
6242
6885
  })();`.trim();
6243
6886
  }
@@ -7133,6 +7776,9 @@ ${guard}
7133
7776
  ...(r.note ? { note: r.note } : {}) };
7134
7777
  })();`.trim();
7135
7778
  }
7779
+ // 'list' is the spelling pinepaper_text_effect uses for the same action,
7780
+ // accepted here so moving between the two tools does not dead-end.
7781
+ case 'list':
7136
7782
  case 'list_styles': {
7137
7783
  return `
7138
7784
  // Text styles + palettes + font axes — the picker surface
@@ -7368,9 +8014,42 @@ ${guard}
7368
8014
  // r.items are live Paper objects — NOT serializable, deliberately dropped.
7369
8015
  // The source item id is GONE unless keepSource was set, so r.ids is now the
7370
8016
  // caller's entire handle set; returning it is what makes the tool usable.
8017
+ // EVERY EFFECT PLAYS FROM t=0, so in a multi-shot video they all fire on the
8018
+ // opening frame and only the first title reads. The engine has no start
8019
+ // delay, so the keyframes it just wrote are shifted here — the same thing a
8020
+ // caller was doing by hand through execute_custom_code.
8021
+ //
8022
+ // Shifted, then held: without an explicit invisible keyframe at 0 the
8023
+ // characters sit in their FIRST pose until the effect starts, which for most
8024
+ // of the 37 is a scatter that looks like a bug. hideAfter closes the other
8025
+ // end, because they otherwise survive to the end of the clip.
8026
+ const _startAt = ${S(input.startAt ?? 0)};
8027
+ const _hideAfter = ${S(input.hideAfter ?? null)};
8028
+ let _shifted = 0;
8029
+ if ((_startAt > 0 || _hideAfter) && Array.isArray(r.ids)) {
8030
+ for (const _id of r.ids) {
8031
+ const _it = app.getItemById && app.getItemById(_id);
8032
+ const _kfs = _it && _it.data && _it.data.keyframes;
8033
+ if (!Array.isArray(_kfs) || _kfs.length === 0) continue;
8034
+ if (_startAt > 0) {
8035
+ for (const _k of _kfs) _k.time += _startAt;
8036
+ _kfs.unshift({ time: 0, properties: { opacity: 0 }, easing: 'linear' });
8037
+ _kfs.splice(1, 0, { time: Math.max(0, _startAt - 0.001), properties: { opacity: 0 }, easing: 'linear' });
8038
+ }
8039
+ if (_hideAfter) {
8040
+ _kfs.push({ time: Math.max(0, _hideAfter - 0.001), properties: { opacity: 1 }, easing: 'linear' });
8041
+ _kfs.push({ time: _hideAfter, properties: { opacity: 0 }, easing: 'linear' });
8042
+ }
8043
+ _kfs.sort((a, b) => a.time - b.time);
8044
+ _shifted++;
8045
+ }
8046
+ }
7371
8047
  return {
7372
8048
  success: true, action: 'apply', effect: r.effect, ids: r.ids, count: r.count,
7373
- duration: r.duration, sourceRemoved: ${S(input.keepSource !== true)},
8049
+ duration: (r.duration || 0) + _startAt, sourceRemoved: ${S(input.keepSource !== true)},
8050
+ ...(_startAt > 0 ? { startAt: _startAt } : {}),
8051
+ ...(_hideAfter ? { hideAfter: _hideAfter } : {}),
8052
+ ...((_startAt > 0 || _hideAfter) ? { retimed: _shifted } : {}),
7374
8053
  };
7375
8054
  })();`.trim();
7376
8055
  }
@@ -7493,7 +8172,7 @@ ${guard}
7493
8172
  // World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
7494
8173
  (async function() {
7495
8174
  if (typeof app.createWorld3D !== 'function') { return { success: false, error: 'app.createWorld3D unavailable — update FxTool to a world3d-capable build' }; }
7496
- await app.createWorld3D(${S(input.spec ?? 'forest')}, ${opts});
8175
+ await app.createWorld3D(${S(world3dColors(input.spec ?? 'forest'))}, ${opts});
7497
8176
  // describe() is the world's own parameter schema — return the preset list +
7498
8177
  // top-level keys so the agent knows what configure can touch, without the
7499
8178
  // full multi-KB schema on every create.
@@ -7514,7 +8193,7 @@ ${guard}
7514
8193
  (function() {
7515
8194
  if (typeof app.configureWorld3D !== 'function') { return { success: false, error: 'app.configureWorld3D unavailable — update FxTool' }; }
7516
8195
  ${needWorld}
7517
- const r = app.configureWorld3D(${S(input.patch)});
8196
+ const r = app.configureWorld3D(${S(world3dColors(input.patch))});
7518
8197
  // The validator names the right key on a wrong one — forward it verbatim.
7519
8198
  return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && r.error) || 'configure failed' };
7520
8199
  })();`.trim();
@@ -7570,7 +8249,7 @@ ${needWorld}
7570
8249
  // World3D: place an object (y defaults to sitting on the terrain)
7571
8250
  (function() {
7572
8251
  ${needWorld}
7573
- const id = app.addWorldObject(${S(input.object)});
8252
+ const id = app.addWorldObject(${S(world3dColors(input.object))});
7574
8253
  return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
7575
8254
  })();`.trim();
7576
8255
  case 'remove_object':
@@ -7935,6 +8614,40 @@ ${needWorld}
7935
8614
  for (const [k, v] of Object.entries(rest))
7936
8615
  if (v !== undefined)
7937
8616
  opts[k] = v;
8617
+ // SPREAD-THROUGH IS NOT THE SAME AS AGREEING.
8618
+ //
8619
+ // Everything above this line reaches app.stickFigure verbatim, which made
8620
+ // the schema look consumed to every guard we have while four fields meant
8621
+ // something the engine does not. The engine compares propSide against
8622
+ // 'L' | 'R', so 'right' — which the schema declared and advertised —
8623
+ // matched neither arm of that test and every prop went to the LEFT hand.
8624
+ // Nothing failed; the figure just held it in the wrong hand, forever.
8625
+ if (action === 'figure') {
8626
+ const side = typeof opts.propSide === 'string' ? opts.propSide : undefined;
8627
+ if (side)
8628
+ opts.propSide = side.toUpperCase().startsWith('R') ? 'R' : 'L';
8629
+ // The engine iterates opts.walk, so `true` — the form the schema itself
8630
+ // advertised as "the default" — walked exactly nowhere.
8631
+ if (opts.walk === true)
8632
+ opts.walk = [{ seconds: 1.2, from: 0 }];
8633
+ else if (opts.walk === false)
8634
+ delete opts.walk;
8635
+ // `facing` is read for chairs and for nothing else. Passing it here
8636
+ // would be accepted and ignored, which is the thing being fixed, so it
8637
+ // is refused by name instead — the add_ports precedent.
8638
+ if (opts.facing !== undefined) {
8639
+ // Built through S() rather than interpolated: the message names two
8640
+ // quoted values, and hand-escaping them into a single-quoted emitted
8641
+ // string produced a syntax error the first time.
8642
+ const msg = `facing is a property of a chair, not of a figure — app.stickFigure `
8643
+ + `never reads it, so ${String(opts.facing)} would have been accepted and ignored. `
8644
+ + `A figure faces the way it travels, so give it travel, or a pose that turns it. `
8645
+ + `facing belongs on action "set" with kind "chair".`;
8646
+ return `
8647
+ // Stick: figure — refused, rather than accepted and ignored
8648
+ ({ success: false, error: ${S(msg)} });`.trim();
8649
+ }
8650
+ }
7938
8651
  const fn = action === 'figure' ? 'stickFigure' : 'stickSet';
7939
8652
  return `
7940
8653
  // Stick: ${action}
@@ -8073,6 +8786,24 @@ ${guard('exportBVH')}
8073
8786
  ${guard('exportPNGSequence')}
8074
8787
  const out = await app.exportPNGSequence(${opts});
8075
8788
  if (!out) { return { success: false, error: 'PNG sequence produced nothing' }; }
8789
+ // A BLOB SERIALISES TO {}. exporter.export() answers a Blob, and returning it
8790
+ // raw produced a success whose result was an empty object — which reads as
8791
+ // "it worked and gave me nothing" rather than "the bytes could not cross
8792
+ // the bridge". Structured clone drops a Blob's
8793
+ // contents; only a string survives.
8794
+ if (typeof Blob !== 'undefined' && out instanceof Blob) {
8795
+ const dataUrl = await new Promise(function(res, rej) {
8796
+ const fr = new FileReader();
8797
+ fr.onload = function() { res(fr.result); };
8798
+ fr.onerror = function() { rej(new Error('could not read the PNG sequence')); };
8799
+ fr.readAsDataURL(out);
8800
+ });
8801
+ return { success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size };
8802
+ }
8803
+ if (Array.isArray(out)) {
8804
+ return { success: true, format: 'png-sequence', frames: out.length,
8805
+ note: 'the frames stayed in the page — pass download: true to write them out.' };
8806
+ }
8076
8807
  return { success: true, format: 'png-sequence', result: out };
8077
8808
  })();`.trim();
8078
8809
  }
@@ -8217,15 +8948,27 @@ ${guard('renderSoundtrackWav')}
8217
8948
  };
8218
8949
  })();`.trim();
8219
8950
  }
8220
- case 'create':
8951
+ case 'create': {
8952
+ const v = (input.visual ?? {});
8953
+ const { startTime, duration, ...visualOnly } = v;
8954
+ const placement = (startTime !== undefined || duration !== undefined)
8955
+ ? { ...(startTime !== undefined ? { startTime } : {}), ...(duration !== undefined ? { duration } : {}) }
8956
+ : null;
8221
8957
  return `
8222
8958
  // Sound: draw it AS a waveform path — the item and the sound are one thing
8223
8959
  (function() {
8224
8960
  ${guard('createSound')}
8225
- const item = app.createSound(${S(input.spec ?? {})}, ${S(input.visual ?? {})});
8961
+ const item = app.createSound(${S(input.spec ?? {})}, ${S(visualOnly)});
8226
8962
  if (!item) { return { success: false, error: 'the sound produced no waveform path' }; }
8227
- return { success: true, itemId: item.data && item.data.id, itemType: 'sound' };
8963
+ const _sid = item.data && item.data.id;
8964
+ // ONE CALL PER CUE, NOT TWO. startTime/duration were stripped from visual,
8965
+ // so placing a cue meant create + set_placement every time — a pilot made
8966
+ // 160 calls where 80 would do. Applied here when given, using the same
8967
+ // setSoundPlacement the separate action calls.
8968
+ ${placement ? `if (_sid && app.setSoundPlacement) { app.setSoundPlacement(_sid, ${S(placement)}); }` : ''}
8969
+ return { success: true, itemId: _sid, itemType: 'sound'${placement ? `, placement: ${S(placement)}` : ''} };
8228
8970
  })();`.trim();
8971
+ }
8229
8972
  case 'timbre_from_path':
8230
8973
  return `
8231
8974
  // Sound: read a drawn path's shape as harmonic content