@pinepaper.studio/mcp-server 1.6.18 → 1.6.19

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 (108) 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 +97 -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 +2 -2
  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 +20 -7
  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/tools/compact-descriptions.d.ts.map +1 -1
  55. package/dist/tools/compact-descriptions.js +15 -14
  56. package/dist/tools/compact-descriptions.js.map +1 -1
  57. package/dist/tools/definitions.d.ts +1 -1
  58. package/dist/tools/definitions.d.ts.map +1 -1
  59. package/dist/tools/definitions.js +422 -126
  60. package/dist/tools/definitions.js.map +1 -1
  61. package/dist/tools/engine-lists.d.ts +17 -2
  62. package/dist/tools/engine-lists.d.ts.map +1 -1
  63. package/dist/tools/engine-lists.js +102 -2
  64. package/dist/tools/engine-lists.js.map +1 -1
  65. package/dist/tools/engine-methods.d.ts.map +1 -1
  66. package/dist/tools/engine-methods.js +14 -1
  67. package/dist/tools/engine-methods.js.map +1 -1
  68. package/dist/tools/engine-surface.d.ts +1 -1
  69. package/dist/tools/engine-surface.d.ts.map +1 -1
  70. package/dist/tools/engine-surface.js +49 -8
  71. package/dist/tools/engine-surface.js.map +1 -1
  72. package/dist/tools/handlers/camera.js +8 -8
  73. package/dist/tools/handlers/camera.js.map +1 -1
  74. package/dist/tools/handlers/export.d.ts.map +1 -1
  75. package/dist/tools/handlers/export.js +77 -5
  76. package/dist/tools/handlers/export.js.map +1 -1
  77. package/dist/tools/handlers/font.js +10 -10
  78. package/dist/tools/handlers/font.js.map +1 -1
  79. package/dist/tools/handlers/generate.d.ts +12 -12
  80. package/dist/tools/handlers/maps.d.ts.map +1 -1
  81. package/dist/tools/handlers/maps.js +23 -3
  82. package/dist/tools/handlers/maps.js.map +1 -1
  83. package/dist/tools/handlers.d.ts +21 -0
  84. package/dist/tools/handlers.d.ts.map +1 -1
  85. package/dist/tools/handlers.js +240 -37
  86. package/dist/tools/handlers.js.map +1 -1
  87. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  88. package/dist/tools/minimal-descriptions.js +32 -25
  89. package/dist/tools/minimal-descriptions.js.map +1 -1
  90. package/dist/tools/shape-params.d.ts +2 -2
  91. package/dist/tools/shape-params.d.ts.map +1 -1
  92. package/dist/tools/shape-params.js +5 -2
  93. package/dist/tools/shape-params.js.map +1 -1
  94. package/dist/tools/stick-vocabulary.d.ts +1 -1
  95. package/dist/tools/stick-vocabulary.js +1 -1
  96. package/dist/tools/toolkits.d.ts.map +1 -1
  97. package/dist/tools/toolkits.js +5 -0
  98. package/dist/tools/toolkits.js.map +1 -1
  99. package/dist/types/code-generator.d.ts +57 -3
  100. package/dist/types/code-generator.d.ts.map +1 -1
  101. package/dist/types/code-generator.js +1007 -253
  102. package/dist/types/code-generator.js.map +1 -1
  103. package/dist/types/schemas.d.ts +1842 -890
  104. package/dist/types/schemas.d.ts.map +1 -1
  105. package/dist/types/schemas.js +229 -51
  106. package/dist/types/schemas.js.map +1 -1
  107. package/manifest.json +22 -2
  108. package/package.json +1 -1
@@ -25,7 +25,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
25
25
  // Custom relation/code types
26
26
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
27
27
  // Mask types
28
- ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, } from './schemas.js';
28
+ ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
29
29
  import { OntologyCompiler } from '../ontology/ontology-compiler.js';
30
30
  // =============================================================================
31
31
  // HELPER FUNCTIONS
@@ -480,6 +480,19 @@ const DIRECTION_KEYS = ['direction', 'textDirection', 'dir'];
480
480
  // first STRONG character decides. Mirrored so a result can say which way
481
481
  // 'auto' went.
482
482
  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;
483
+ /**
484
+ * cursiveOptions as createCursiveText reads them. The engine takes `nib` as a
485
+ * boolean or a tuning object; the tool also takes the words 'monoline' and
486
+ * 'broad', which the engine would read as truthy (= broad) both times.
487
+ */
488
+ export function cursiveEngineOptions(o) {
489
+ if (!o)
490
+ return {};
491
+ const { nib, ...rest } = o;
492
+ if (nib === undefined)
493
+ return rest;
494
+ return { ...rest, nib: nib === 'monoline' ? false : nib === 'broad' ? true : nib };
495
+ }
483
496
  function baseDirection(text) {
484
497
  for (const ch of text) {
485
498
  if (RTL_SCRIPT.test(ch))
@@ -1477,7 +1490,9 @@ function generateGetItemsCode(filter) {
1477
1490
  if (filter?.source)
1478
1491
  conditions.push(`entry.source === '${filter.source}'`);
1479
1492
  if (filter?.hasAnimation !== undefined) {
1480
- conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
1493
+ // 'none' is what the engine stores on a still item, and !!'none' is true
1494
+ // (gate H5): animated means a real loop animation OR keyframes.
1495
+ 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
1496
  }
1482
1497
  if (filter?.hasRelation !== undefined) {
1483
1498
  conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
@@ -1490,7 +1505,7 @@ const items = entries.map(entry => ({
1490
1505
  id: entry.id || entry.itemId,
1491
1506
  type: entry.type,
1492
1507
  position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
1493
- hasAnimation: !!entry.item.data?.animationType,
1508
+ hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
1494
1509
  relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
1495
1510
  }));
1496
1511
 
@@ -1505,7 +1520,7 @@ const items = filtered.map(entry => ({
1505
1520
  id: entry.id || entry.itemId,
1506
1521
  type: entry.type,
1507
1522
  position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
1508
- hasAnimation: !!entry.item.data?.animationType,
1523
+ hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
1509
1524
  relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
1510
1525
  }));
1511
1526
 
@@ -1679,7 +1694,7 @@ app.setBackgroundColor('${color}');
1679
1694
  */
1680
1695
  function generateSetCanvasSizeCode(width, height, preset) {
1681
1696
  const sizeArg = preset
1682
- ? `'${preset}'`
1697
+ ? JSON.stringify(preset)
1683
1698
  : `{ width: ${width}, height: ${height} }`;
1684
1699
  return `
1685
1700
  // Set canvas size
@@ -1697,9 +1712,10 @@ function generateSetCanvasSizeCode(width, height, preset) {
1697
1712
  app.historyManager.saveState();
1698
1713
  // Report what the canvas ACTUALLY took where the engine says so, rather than
1699
1714
  // echoing the request back.
1700
- return { success: true, width: (r && r.width) || ${width}, height: (r && r.height) || ${height},
1715
+ const __sz = app.getCanvasSize ? app.getCanvasSize() : null;
1716
+ return { success: true, width: (r && r.width) || (__sz && __sz.width) || ${width ?? null}, height: (r && r.height) || (__sz && __sz.height) || ${height ?? null},
1701
1717
  ...(r && r.preset ? { preset: r.preset } : {}),
1702
- ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {})${!preset && (width > 8192 || height > 8192) ? `,
1718
+ ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {})${!preset && ((width ?? 0) > 8192 || (height ?? 0) > 8192) ? `,
1703
1719
  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
1720
  })();
1705
1721
  `.trim();
@@ -2210,8 +2226,11 @@ function world3dColor(value) {
2210
2226
  return value;
2211
2227
  const hex = value.trim().replace(/^#/, '');
2212
2228
  const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
2229
+ // A string that is not hex is refused HERE, naming both forms: passed on,
2230
+ // the engine answered "must be [r,g,b]", which contradicted the docs
2231
+ // saying hex works (gate A, 1.6.19).
2213
2232
  if (!/^[0-9a-f]{6}$/i.test(full))
2214
- return value;
2233
+ throw new Error(`"${value}" is not a colour: use hex ("#03050c" or "#abc") or [r, g, b]`);
2215
2234
  return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
2216
2235
  }
2217
2236
  /**
@@ -2313,6 +2332,52 @@ export const SHEET_RUBRIC = {
2313
2332
  'a jump between the last and first frame of a loop (loopSeam)',
2314
2333
  ],
2315
2334
  };
2335
+ /**
2336
+ * Page code: what a project holds, counted the same way before a save and
2337
+ * after a load, so a round trip can be checked (keyframes in = keyframes out).
2338
+ */
2339
+ const SCENE_COUNTS_JS = `function __ppSceneCounts() {
2340
+ const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
2341
+ let animated = 0, keyframes = 0;
2342
+ all.forEach(function (e) {
2343
+ const k = e && e.item && e.item.data && e.item.data.keyframes;
2344
+ if (Array.isArray(k) && k.length) { animated++; keyframes += k.length; }
2345
+ });
2346
+ const rel = (app.relationRegistry && typeof app.relationRegistry.exportForSave === 'function') ? app.relationRegistry.exportForSave().length : null;
2347
+ return { items: all.length, animatedItems: animated, keyframes: keyframes, relations: rel };
2348
+ }`;
2349
+ /**
2350
+ * Page code: the REGISTRY id for a Paper item, walking up from a child (a hit
2351
+ * test lands on a group's inner path). Results used to fall back to Paper's
2352
+ * internal numeric id (gate D9/D10, 1.6.19), which no other tool can address.
2353
+ */
2354
+ const REG_ID_JS = `function __ppRegId(it) {
2355
+ for (let n = it; n; n = n.parent) {
2356
+ const id = n.data && (n.data.id || n.data.registryId);
2357
+ if (id && app.itemRegistry && app.itemRegistry.get(id)) return id;
2358
+ }
2359
+ const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
2360
+ for (let n = it; n; n = n.parent) { const e = all.find(function (x) { return x && x.item === n; }); if (e) return e.itemId; }
2361
+ return null;
2362
+ }`;
2363
+ /**
2364
+ * Page code: any binary an engine export returns (Blob, ArrayBuffer, typed
2365
+ * array) as a data URL, so it crosses the bridge as bytes and the handler can
2366
+ * write a file. A Uint8Array crossed as a JSON object of byte indices
2367
+ * ({"0": 80, "1": 75, ...}): export_dotlottie was unusable (gate D7, 1.6.19).
2368
+ */
2369
+ const TO_DATA_URL_JS = `async function __ppToDataUrl(out, mime) {
2370
+ if (typeof out === 'string') return out.indexOf('data:') === 0 ? out : null;
2371
+ let blob = null;
2372
+ if (out instanceof Blob) blob = out;
2373
+ else if (out instanceof ArrayBuffer || ArrayBuffer.isView(out)) blob = new Blob([out], { type: mime });
2374
+ if (!blob) return null;
2375
+ return await new Promise(function (res, rej) { const r = new FileReader(); r.onload = function () { res(r.result); }; r.onerror = rej; r.readAsDataURL(blob); });
2376
+ }`;
2377
+ /** Why map pan / zoom refuse, and what to do instead. */
2378
+ 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, `
2379
+ + 'which a canvas export ignores. To frame part of a map in an export: keyframe the camera with pinepaper_camera_animate '
2380
+ + 'and export with framing:"camera".';
2316
2381
  export class PinePaperCodeGenerator {
2317
2382
  /**
2318
2383
  * Generate code for creating an item
@@ -2354,8 +2419,14 @@ export class PinePaperCodeGenerator {
2354
2419
  /**
2355
2420
  * Generate code for adding a relation
2356
2421
  */
2357
- generateAddRelation(input) {
2358
- const validated = AddRelationInputSchema.parse(input);
2422
+ generateAddRelation(input, customName = false) {
2423
+ // The handler has already parsed, and a session-registered custom
2424
+ // relation name is not in the enum (gate D2); the handler says so, and the
2425
+ // name is re-parsed with a built-in stand-in and put back.
2426
+ const rt = input.relationType;
2427
+ const validated = AddRelationInputSchema.parse(customName ? { ...input, relationType: 'orbits' } : input);
2428
+ if (customName)
2429
+ validated.relationType = rt;
2359
2430
  // A PRESET decides its own relationType and params, so this is a different
2360
2431
  // call, not a defaulted one. applyRelationPreset refuses with
2361
2432
  // `{ok:false, errors:[{field, reason}]}` — a third refusal shape on this
@@ -3068,9 +3139,12 @@ if (typeof _gen === 'function') {
3068
3139
  codeParts.push(`
3069
3140
  // Save history and return results
3070
3141
  app.historyManager.saveState();
3071
- results;
3142
+ return results;
3072
3143
  `);
3073
- return codeParts.join('\n');
3144
+ // ONE (-led FUNCTION (gate C4, 1.6.19). This was a statement list ending
3145
+ // in a bare \`results;\`, and the governor keeps only a (-led snippet's
3146
+ // value, so every created id was collected and then dropped.
3147
+ return `(async function() {\n${codeParts.join('\n')}\n})();`;
3074
3148
  }
3075
3149
  // =============================================================================
3076
3150
  // DIAGRAM TOOLS
@@ -3191,12 +3265,26 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
3191
3265
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
3192
3266
 
3193
3267
  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;
3268
+ // connectPorts(sourcePort, targetPort, config) takes PORT OBJECTS (gate C8,
3269
+ // 1.6.19). This passed (item, name, item, name, config): five arguments to a
3270
+ // three-argument method, so the engine got an item where a port belongs,
3271
+ // returned null, and connector.data threw. Ports are resolved by name or
3272
+ // position first; an item with none gets the standard diagram ports.
3273
+ const __pm = app.diagramSystem.portManager;
3274
+ const __port = function (item, name) {
3275
+ let p = __pm.getPort(item, name);
3276
+ if (!p && !(__pm.getPorts(item) || []).length) { app.diagramSystem.addPorts(item); p = __pm.getPort(item, name); }
3277
+ return p;
3278
+ };
3279
+ const __sp = __port(sourceItem, ${JSON.stringify(sourcePort)});
3280
+ const __tp = __port(targetItem, ${JSON.stringify(targetPort)});
3281
+ if (!__sp || !__tp) {
3282
+ const __names = function (item) { return (__pm.getPorts(item) || []).map(function (p) { return p.position || p.id; }).join(', '); };
3283
+ 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) + ')') };
3284
+ }
3285
+ const connector = app.diagramSystem.connectPorts(__sp, __tp, config);
3286
+ if (!connector) { return { success: false, error: 'the diagram system did not create the connector' }; }
3287
+ const connectorId = connector.id || connector.data?.registryId;
3200
3288
  app.historyManager.saveState();
3201
3289
 
3202
3290
  ({ connectorId, sourceItemId: '${sourceItemId}', sourcePort: '${sourcePort}', targetItemId: '${targetItemId}', targetPort: '${targetPort}' });
@@ -3291,61 +3379,50 @@ const shapes = category ? _lib.getByCategory(category) : _lib.getAll();
3291
3379
  generateUpdateConnector(input) {
3292
3380
  const validated = UpdateConnectorInputSchema.parse(input);
3293
3381
  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
3382
  return `
3306
3383
  // 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' }; }
3384
+ (function() {
3385
+ // CONNECTORS LIVE IN THE DIAGRAM SYSTEM, NOT THE ITEM REGISTRY (gate D1,
3386
+ // 1.6.19): getItemById never resolves the id connect returned.
3387
+ const __cid = ${JSON.stringify(connectorId)};
3388
+ const ds = app.diagramSystem;
3389
+ const connector = ds && typeof ds.getConnector === 'function' ? ds.getConnector(__cid) : null;
3390
+ if (!connector) return { success: false, error: 'Connector not found: ' + __cid + '. Use the connectorId pinepaper_connect returned.' };
3391
+ // updateConnector(id, style) takes the STYLE BAG ITSELF. This passed
3392
+ // { style: {...}, label } (gate run 2, 1.6.19), so the engine merged a key
3393
+ // named "style" into the connector's style, changed nothing it draws, and
3394
+ // the tool reported updated:true over an unchanged connector.
3395
+ const style = ${JSON.stringify(style ?? {})};
3396
+ const label = ${label === undefined ? 'undefined' : JSON.stringify(label)};
3397
+ const labelPosition = ${labelPosition === undefined ? 'undefined' : labelPosition};
3398
+ const keys = Object.keys(style);
3399
+ if (!keys.length && label === undefined && labelPosition === undefined) {
3400
+ return { success: false, error: 'nothing to update: pass style, label or labelPosition' };
3401
+ }
3402
+ const applied = [];
3403
+ if (keys.length) {
3404
+ if (typeof ds.updateConnector !== 'function') {
3405
+ return { success: false, error: 'this studio has no per-connector update (app.diagramSystem.updateConnector); update the studio' };
3406
+ }
3407
+ const res = ds.updateConnector(__cid, style);
3408
+ if (!res || res.ok === false) return { success: false, error: (res && res.reason) || 'the connector could not be updated' };
3409
+ const unchanged = keys.filter(function (k) { return connector.style[k] !== style[k]; });
3410
+ if (unchanged.length) return { success: false, error: 'the studio did not take ' + unchanged.join(', ') };
3411
+ keys.forEach(function (k) { applied.push('style.' + k); });
3412
+ }
3413
+ if (label !== undefined || labelPosition !== undefined) {
3414
+ connector.setLabel(label !== undefined ? label : connector.label, labelPosition !== undefined ? labelPosition : connector.labelPosition);
3415
+ applied.push(label !== undefined ? 'label' : 'labelPosition');
3416
+ }
3327
3417
  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 });
3418
+ // What it now DRAWS, read off the path, so a caller can check rather than trust.
3419
+ const p = connector.path;
3420
+ return {
3421
+ success: true, connectorId: __cid, updated: true, applied: applied,
3422
+ drawn: p ? { stroke: p.strokeColor ? p.strokeColor.toCSS(true) : null, width: p.strokeWidth, dash: p.dashArray || [] } : null,
3423
+ label: connector.label || null,
3424
+ };
3425
+ })();
3349
3426
  `.trim();
3350
3427
  }
3351
3428
  /**
@@ -3356,8 +3433,10 @@ if (_unsupported.length && !_applied.length) {
3356
3433
  const { connectorId } = validated;
3357
3434
  return `
3358
3435
  // Remove connector: ${connectorId}
3359
- const connector = app.getItemById('${connectorId}');
3360
- if (!connector) throw new Error('Connector not found: ${connectorId}');
3436
+ // Resolved through the diagram system (gate D1, 1.6.19), as update_connector is.
3437
+ const __cid = ${JSON.stringify(connectorId)};
3438
+ const connector = (app.diagramSystem && app.diagramSystem.getConnector(__cid)) || app.getItemById(__cid);
3439
+ if (!connector) throw new Error('Connector not found: ' + __cid);
3361
3440
 
3362
3441
  app.diagramSystem.connectorManager.removeConnector(connector);
3363
3442
  app.historyManager.saveState();
@@ -3446,6 +3525,23 @@ throw new Error('Unknown diagram mode action: ${action}');
3446
3525
  app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
3447
3526
  // Allow canvas resize to take effect
3448
3527
  await new Promise(r => setTimeout(r, 50));
3528
+ `;
3529
+ }
3530
+ else {
3531
+ code += `
3532
+ // BOUND THE BOARD IT REPORTS (gate D57). An UNBOUNDED canvas still answers
3533
+ // getCanvasSize with its 1920x1080 fallback, which is what this job reports
3534
+ // and what every sizing rule here is relative to; but its stills export the
3535
+ // artwork's bounds plus a margin (432x232 for one rectangle), while video
3536
+ // exports the board. Fixing it at the size reported makes the two agree.
3537
+ let _boundedFrom = null;
3538
+ if (app.canvasSize && app.canvasSize.unbounded && typeof app.setCanvasSize === 'function') {
3539
+ const _u = app.getCanvasSize ? app.getCanvasSize() : app.canvasSize;
3540
+ if (_u && _u.width > 0 && _u.height > 0) {
3541
+ const _r = app.setCanvasSize({ width: _u.width, height: _u.height });
3542
+ if (!(_r && _r.ok === false)) _boundedFrom = 'unbounded';
3543
+ }
3544
+ }
3449
3545
  `;
3450
3546
  }
3451
3547
  if (wantOntology) {
@@ -3479,7 +3575,8 @@ throw new Error('Unknown diagram mode action: ${action}');
3479
3575
  screenshotPolicy: '${policyStr}',
3480
3576
  canvasPreset: ${canvasPreset ? `'${canvasPreset}'` : 'null'},
3481
3577
  canvasCleared: ${shouldClear},
3482
- canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }${wantOntology ? `,
3578
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }${canvasPreset ? '' : `,
3579
+ ...(_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
3580
  ontology: _ontology,
3484
3581
  ontologyUnavailable: _ontologyUnavailable || undefined` : ''}
3485
3582
  };
@@ -3558,12 +3655,13 @@ throw new Error('Unknown diagram mode action: ${action}');
3558
3655
 
3559
3656
  if (entry.type === 'text' || item.className === 'PointText') analysis.hasText = true;
3560
3657
  if (item.className === 'Raster') analysis.hasImages = true;
3561
- if (data.animationType) {
3658
+ // 'none' is what a still item stores (gate C5, 1.6.19): it is not an animation.
3659
+ if (data.animationType && data.animationType !== 'none') {
3562
3660
  analysis.hasAnimations = true;
3563
3661
  animationSet.add(data.animationType);
3564
3662
  }
3565
3663
  // Detect keyframe animations (added via app.addAnimation with keyframes array)
3566
- if (data.keyframes || data.animation || data.keyframeAnimation) {
3664
+ if ((Array.isArray(data.keyframes) && data.keyframes.length > 0) || data.animation || data.keyframeAnimation) {
3567
3665
  analysis.hasAnimations = true;
3568
3666
  animationSet.add('keyframe');
3569
3667
  }
@@ -3915,7 +4013,7 @@ return { success: true, backgroundColor: '${bgColor}' };
3915
4013
  case 'set_canvas_size': {
3916
4014
  const w = op.width || 1080;
3917
4015
  const h = op.height || 1080;
3918
- const sizeArg = op.preset ? `'${op.preset}'` : `{ width: ${w}, height: ${h} }`;
4016
+ const sizeArg = op.preset ? JSON.stringify(op.preset) : `{ width: ${w}, height: ${h} }`;
3919
4017
  return `
3920
4018
  // Read the verdict, as the standalone tool now does: an unknown preset or an
3921
4019
  // out-of-range dimension is refused by name, and reporting the size we ASKED
@@ -4403,7 +4501,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4403
4501
  // A 3D world under the canvas IS the background: since FxTool #50
4404
4502
  // (747315e3) jpg/webp stills composite it, so nothing turns white/black.
4405
4503
  const hasWorld = !!app._world3d;
4406
- if (hasColor || hasBgItems || hasWorld) return [];
4504
+ // So does a styled scene: one raster sized to COVER the frame (gate run 3:
4505
+ // every export after styled_scene warned of black areas it does not have).
4506
+ const hasStyledScene = !!(app.textItemGroup && app.textItemGroup.children
4507
+ && app.textItemGroup.children.some(function (it) { return it && it.data && it.data.styledScene && it.visible !== false; }));
4508
+ if (hasColor || hasBgItems || hasWorld || hasStyledScene) return [];
4407
4509
  return [{ code: 'alpha_dropped', message: fmt === 'apng'
4408
4510
  ? '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
4511
  : fmt === 'jpg'
@@ -4530,7 +4632,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4530
4632
  camBase = { width: app.canvasSize.width, height: app.canvasSize.height };
4531
4633
  }
4532
4634
  if (!camBase) camBase = { width: dimensions.width, height: dimensions.height };
4533
- cameraDims = { width: camBase.width, height: camBase.height };
4635
+ // scale applies here too: it was dropped, so scale 0.5 rendered full
4636
+ // size and nothing said so (gate C7).
4637
+ const __cs = ${scale !== undefined ? scale : 1};
4638
+ const __ev = (n) => Math.max(2, Math.round((n * __cs) / 2) * 2);
4639
+ cameraDims = { width: __ev(camBase.width), height: __ev(camBase.height) };
4534
4640
  }
4535
4641
 
4536
4642
  // AN EXPORTER ANSWERS A BLOB OR A RECORD AROUND ONE.
@@ -4693,6 +4799,12 @@ ${stillTime !== undefined ? `
4693
4799
  ? { width: png.width, height: png.height }
4694
4800
  : { width: dimensions.width, height: dimensions.height },
4695
4801
  };
4802
+ // An unbounded canvas exports its ARTWORK (bounds plus a margin), not
4803
+ // the board getCanvasSize reports (gate D57). Say so rather than
4804
+ // hand back a size nobody asked for unexplained.
4805
+ if (app.canvasSize && app.canvasSize.unbounded) {
4806
+ 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.';
4807
+ }
4696
4808
  } else {
4697
4809
  const canvas = document.querySelector('canvas');
4698
4810
  if (canvas) {
@@ -5369,6 +5481,21 @@ ${(() => {
5369
5481
  })()}
5370
5482
  ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
5371
5483
  if (result && result.success) result.time = ${stillTime};` : ''}
5484
+ // RELIGHT (FxTool D5): applied to png / pdf / mp4 / webm, not gif / apng
5485
+ // or the vector formats; and it is what makes a video export slow.
5486
+ if (result && result.success && typeof app.getRelight === 'function') {
5487
+ try {
5488
+ const __rl = app.getRelight();
5489
+ if (__rl && __rl.enabled) {
5490
+ result.fidelity = result.fidelity || { warnings: [] };
5491
+ if (['png', 'jpg', 'webp', 'pdf', 'mp4', 'webm'].indexOf(${JSON.stringify(format)}) === -1) {
5492
+ 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.' }]);
5493
+ } else {
5494
+ result.relight = { applied: true, lights: (__rl.lights || []).length };
5495
+ }
5496
+ }
5497
+ } catch (_) { /* the export stands */ }
5498
+ }
5372
5499
 
5373
5500
  return result;
5374
5501
  })();
@@ -5436,12 +5563,13 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5436
5563
  if (entry.type === 'text' || item.className === 'PointText') analysis.hasText = true;
5437
5564
  if (item.className === 'Raster') analysis.hasImages = true;
5438
5565
 
5439
- if (data.animationType) {
5566
+ // 'none' is what a still item stores (gate C5, 1.6.19): it is not an animation.
5567
+ if (data.animationType && data.animationType !== 'none') {
5440
5568
  analysis.hasAnimations = true;
5441
5569
  animationSet.add(data.animationType);
5442
5570
  }
5443
5571
  // Detect keyframe animations (added via app.addAnimation with keyframes array)
5444
- if (data.keyframes || data.animation || data.keyframeAnimation) {
5572
+ if ((Array.isArray(data.keyframes) && data.keyframes.length > 0) || data.animation || data.keyframeAnimation) {
5445
5573
  analysis.hasAnimations = true;
5446
5574
  animationSet.add('keyframe');
5447
5575
  }
@@ -5693,7 +5821,9 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5693
5821
  return {
5694
5822
  success: true,
5695
5823
  mapId: result.mapId || '${mapId}',
5696
- regions: result.regions?.length || 0,
5824
+ // regionPaths is a Map: .length was undefined, so every load reported
5825
+ // regions: 0 (gate D3). size, with the feature count as a fallback.
5826
+ regions: (result.regions && (result.regions.size ?? result.regions.length)) || (result.geoData && result.geoData.features ? result.geoData.features.length : 0),
5697
5827
  bounds: result.bounds,
5698
5828
  center: result.center,${warn}
5699
5829
  };
@@ -5702,6 +5832,55 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5702
5832
  }
5703
5833
  })();
5704
5834
  `.trim();
5835
+ }
5836
+ /**
5837
+ * Emitted helpers for reading a map's region colours back.
5838
+ *
5839
+ * highlightRegions and applyDataColors both write ONE channel — a colour
5840
+ * override keyed on the feature's _mapId — and log a console.warn for an id
5841
+ * they cannot resolve, which production strips. getHighlightedRegions reads
5842
+ * path.data.highlighted over regionPaths, which a rasterised map leaves
5843
+ * empty, so it saw nothing either tool did (gate D51). The override, read
5844
+ * through the public getRegionOverride, is the record the engine leaves.
5845
+ * Ids resolve the way the engine resolves them: exact, then lower-case.
5846
+ */
5847
+ mapRegionReadbackJs() {
5848
+ return `
5849
+ function __resolve(id) {
5850
+ const m = app.mapSystem.regionNameMappings;
5851
+ if (!m || typeof m.get !== 'function') return null;
5852
+ return m.get(id) || m.get(String(id).toLowerCase()) || null;
5853
+ }
5854
+ function __features() {
5855
+ const seen = new Map();
5856
+ app.mapSystem.regionNameMappings.forEach(function (f) { if (f && !seen.has(f._mapId)) seen.set(f._mapId, f); });
5857
+ return Array.from(seen.values());
5858
+ }
5859
+ function __row(f, asked) {
5860
+ const o = app.mapSystem.getRegionOverride(f._mapId);
5861
+ const r = { regionId: f._mapId, name: f._mapName, fill: o ? o.fill : null };
5862
+ if (asked !== undefined) r.asked = asked;
5863
+ return r;
5864
+ }
5865
+ function __overridden() {
5866
+ return __features().filter(function (f) { return !!app.mapSystem.getRegionOverride(f._mapId); })
5867
+ .map(function (f) { return __row(f); });
5868
+ }
5869
+ // No map loaded: every id is "not on this map", which blames the ids.
5870
+ const __noMap = { success: false, error: 'no map is loaded. Load one with pinepaper_map {action: "load"} first.' };
5871
+ function __sampleIds() {
5872
+ return __features().slice(0, 8).map(function (f) { return f._mapId + ' (' + f._mapName + ')'; }).join(', ');
5873
+ }
5874
+ function __check(asked) {
5875
+ const done = [], notFound = [], notApplied = [];
5876
+ asked.forEach(function (id) {
5877
+ const f = __resolve(id);
5878
+ if (!f) { notFound.push(id); return; }
5879
+ const r = __row(f, id);
5880
+ (r.fill ? done : notApplied).push(r);
5881
+ });
5882
+ return { done: done, notFound: notFound, notApplied: notApplied };
5883
+ }`;
5705
5884
  }
5706
5885
  /**
5707
5886
  * Generate code to highlight map regions
@@ -5720,13 +5899,19 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5720
5899
  const fill = ho.color ?? ho.fillColor ?? ho.fill;
5721
5900
  if (fill !== undefined)
5722
5901
  style.fill = fill;
5902
+ // OPACITY IS A TINT OVER THE LAND (gate D59). The override REPLACES a
5903
+ // region's land fill rather than drawing over it, and an rgba fill lost
5904
+ // its alpha on the way to the exported pixels, so the readback reported a
5905
+ // blend the PNG did not draw. The colour is blended over each region's own
5906
+ // land fill in the browser and sent opaque: export and readback agree.
5907
+ const opacity = typeof ho.opacity === 'number' ? ho.opacity : null;
5723
5908
  const stroke = ho.strokeColor ?? ho.stroke;
5724
5909
  if (stroke !== undefined)
5725
5910
  style.stroke = stroke;
5726
5911
  if (ho.strokeWidth !== undefined)
5727
5912
  style.strokeWidth = ho.strokeWidth;
5728
5913
  for (const [k, v] of Object.entries(ho)) {
5729
- if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth'].includes(k) && v !== undefined) {
5914
+ if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth', 'opacity'].includes(k) && v !== undefined) {
5730
5915
  style[k] = v;
5731
5916
  }
5732
5917
  }
@@ -5738,9 +5923,68 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5738
5923
  return { success: false, error: 'Map system not available' };
5739
5924
  }
5740
5925
 
5741
- try {
5742
- app.mapSystem.highlightRegions(${regionIds}, ${optionsStr});
5743
- return { success: true, highlighted: ${regionIds} };
5926
+ try {${this.mapRegionReadbackJs()}
5927
+ if (!__features().length) return __noMap;
5928
+ const __style = ${optionsStr};
5929
+ const __op = ${JSON.stringify(opacity)};
5930
+ const __expected = {};
5931
+ const __unblended = [];
5932
+ if (__op !== null && typeof __style.fill === 'string') {
5933
+ const __rgb = function (c) {
5934
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || ''));
5935
+ if (!m) return null;
5936
+ const x = m[1].length === 3 ? m[1].replace(/./g, function (ch) { return ch + ch; }) : m[1];
5937
+ return [0, 2, 4].map(function (i) { return parseInt(x.slice(i, i + 2), 16); });
5938
+ };
5939
+ const __src = __rgb(__style.fill);
5940
+ const __p = app.mapSystem._mapRenderParams;
5941
+ const __ms = (__p && __p.mergedStyles) || {};
5942
+ const __user = (app.mapSystem.currentMap && app.mapSystem.currentMap.geoData && app.mapSystem.currentMap.geoData.features) || [];
5943
+ ${regionIds}.forEach(function (id) {
5944
+ const f = __resolve(id);
5945
+ if (!f) return;
5946
+ // The land the engine paints under this region: a user feature takes
5947
+ // the map's fill, a basemap country its basemapFill.
5948
+ const __land = __rgb(__user.indexOf(f) !== -1 ? __ms.fill
5949
+ : (__ms.basemapFill || (app.mapSystem.defaultStyles || {}).basemapFill));
5950
+ let __fill = __style.fill;
5951
+ if (__src && __land) {
5952
+ __fill = '#' + [0, 1, 2].map(function (i) {
5953
+ return ('0' + Math.round(__src[i] * __op + __land[i] * (1 - __op)).toString(16)).slice(-2);
5954
+ }).join('');
5955
+ } else {
5956
+ __unblended.push(id);
5957
+ }
5958
+ __expected[id] = __fill;
5959
+ app.mapSystem.highlightRegions([id], Object.assign({}, __style, { fill: __fill }));
5960
+ });
5961
+ } else {
5962
+ app.mapSystem.highlightRegions(${regionIds}, __style);
5963
+ }
5964
+ // READ BACK (gate D16, D51): every id is resolved and its colour read.
5965
+ const __r = __check(${regionIds});
5966
+ if (!__r.done.length) {
5967
+ return { success: false, notFound: __r.notFound, notApplied: __r.notApplied,
5968
+ error: 'no region was highlighted.'
5969
+ + (__r.notFound.length ? ' Not on this map: ' + __r.notFound.join(', ') + '. Ids on this map include ' + __sampleIds() + '; names work too.' : '') };
5970
+ }
5971
+ // The colour asked for must be the colour drawn (gate D59: the readback
5972
+ // reported #3b82f6 for a #e11d48 request and still said success).
5973
+ const __want = ${JSON.stringify(style.fill ?? null)};
5974
+ if (__want) {
5975
+ const __exp = function (d) { return __expected[d.asked] || __want; };
5976
+ const __off = __r.done.filter(function (d) { return String(d.fill).toLowerCase() !== String(__exp(d)).toLowerCase(); });
5977
+ if (__off.length === __r.done.length) {
5978
+ return { success: false, highlighted: __r.done, error: 'the regions were highlighted in ' + __off[0].fill + ', not the ' + __exp(__off[0]) + ' asked for.' };
5979
+ }
5980
+ if (__off.length) __r.wrongFill = __off;
5981
+ }
5982
+ const __res = { success: true, highlighted: __r.done };
5983
+ if (__unblended.length) { __res.unblended = __unblended; __res.warning = 'opacity needs a hex colour and a hex land fill; drawn at full strength: ' + __unblended.join(', ') + '.'; }
5984
+ 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(', ') + '.'; }
5985
+ 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() + '.'; }
5986
+ if (__r.notApplied.length) __res.notApplied = __r.notApplied;
5987
+ return __res;
5744
5988
  } catch (error) {
5745
5989
  return { success: false, error: error.message };
5746
5990
  }
@@ -5821,9 +6065,20 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5821
6065
  return { success: false, error: 'Map system not available' };
5822
6066
  }
5823
6067
 
5824
- try {
5825
- app.mapSystem.applyDataColors(${dataStr}, ${optionsStr});
5826
- return { success: true, regionsColored: ${Object.keys(validated.data).length} };
6068
+ try {${this.mapRegionReadbackJs()}
6069
+ if (!__features().length) return __noMap;
6070
+ const __legend = app.mapSystem.applyDataColors(${dataStr}, ${optionsStr});
6071
+ // READ BACK (gate D51): the engine skips a key it cannot resolve with a
6072
+ // console warning, which production strips, so a misspelt key looked coloured.
6073
+ const __r = __check(${JSON.stringify(Object.keys(validated.data))});
6074
+ if (!__r.done.length) {
6075
+ return { success: false, unmatched: __r.notFound,
6076
+ error: 'no region was coloured: none of the keys is on this map. Ids on this map include ' + __sampleIds() + '; names work too.' };
6077
+ }
6078
+ const __res = { success: true, regionsColored: __r.done.length, colored: __r.done };
6079
+ if (__legend && __legend.id !== undefined) __res.legendId = __legend.id;
6080
+ if (__r.notFound.length) { __res.unmatched = __r.notFound; __res.warning = 'not on this map, so not coloured: ' + __r.notFound.join(', ') + '.'; }
6081
+ return __res;
5827
6082
  } catch (error) {
5828
6083
  return { success: false, error: error.message };
5829
6084
  }
@@ -5857,8 +6112,19 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5857
6112
  }
5858
6113
 
5859
6114
  try {
5860
- const marker = app.mapSystem.addMarker(${JSON.stringify(options)});
5861
- return { success: true, markerId: marker?.id || 'marker_added' };
6115
+ // addMarker(longitude, latitude, options). One object went in as the
6116
+ // longitude, projected to NaN, and the engine returned null while this
6117
+ // reported success with the made-up id 'marker_added' (gate D51).
6118
+ const __o = ${JSON.stringify(options)};
6119
+ const marker = app.mapSystem.addMarker(__o.lon, __o.lat, __o);
6120
+ if (!marker) {
6121
+ return { success: false, error: 'no marker was added: [' + __o.lat + ', ' + __o.lon + '] (lat, lon) is outside the visible map, or no map is loaded.' };
6122
+ }
6123
+ // A REGISTRY id, so modify / animate / delete can take it. register()
6124
+ // leaves the marker inside the map group; registerItem would move it out,
6125
+ // and it would stop panning and clearing with the map.
6126
+ const markerId = app.itemRegistry.register(marker, 'mapMarker', { lat: __o.lat, lon: __o.lon, label: __o.label || null }, 'user');
6127
+ return { success: true, markerId: markerId, position: [marker.position.x, marker.position.y] };
5862
6128
  } catch (error) {
5863
6129
  return { success: false, error: error.message };
5864
6130
  }
@@ -5908,26 +6174,13 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5908
6174
  options.animate = validated.animate;
5909
6175
  if (validated.duration !== undefined)
5910
6176
  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();
6177
+ // REFUSED, HONESTLY (gate run 2, 1.6.19). Run 1 turned pan into a camera
6178
+ // pan, which moves the studio VIEW but not the map: a canvas-framed export
6179
+ // ignores the camera, so the call reported success and the export was
6180
+ // unchanged. The map itself cannot be moved without breaking geoToCanvas
6181
+ // (it projects against the untransformed map, so markers would land in the
6182
+ // wrong place). Until the engine has a map view transform, say so.
6183
+ return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('pan'))} }; })();`;
5931
6184
  }
5932
6185
  /**
5933
6186
  * Generate code to zoom the map
@@ -5939,24 +6192,8 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
5939
6192
  options.animate = validated.animate;
5940
6193
  if (validated.duration !== undefined)
5941
6194
  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();
6195
+ // Refused for the same reason as pan (see generatePanMap).
6196
+ return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('zoom'))} }; })();`;
5960
6197
  }
5961
6198
  /**
5962
6199
  * Generate code to export map configuration
@@ -6001,7 +6238,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6001
6238
 
6002
6239
  try {
6003
6240
  const result = await app.mapSystem.importCustomMap('${validated.url}', ${optionsStr});
6004
- return { success: true, ...result };
6241
+ // A summary, not the engine's return: that carries the Paper group and
6242
+ // the region Map, which serialised into a raw Paper.js dump (gate N1).
6243
+ const __r = result && result.regions;
6244
+ const __n = (__r && (__r.size ?? __r.length)) || (result && result.geoData && result.geoData.features ? result.geoData.features.length : 0);
6245
+ 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
6246
  } catch (error) {
6006
6247
  return { success: false, error: error.message };
6007
6248
  }
@@ -6018,7 +6259,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6018
6259
 
6019
6260
  try {
6020
6261
  const result = await app.mapSystem.importCustomMap(${JSON.stringify(validated.geoJson)}, ${optionsStr});
6021
- return { success: true, ...result };
6262
+ // A summary, not the engine's return: that carries the Paper group and
6263
+ // the region Map, which serialised into a raw Paper.js dump (gate N1).
6264
+ const __r = result && result.regions;
6265
+ const __n = (__r && (__r.size ?? __r.length)) || (result && result.geoData && result.geoData.features ? result.geoData.features.length : 0);
6266
+ 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
6267
  } catch (error) {
6023
6268
  return { success: false, error: error.message };
6024
6269
  }
@@ -6045,20 +6290,17 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6045
6290
  }
6046
6291
 
6047
6292
  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' };
6293
+ if (!app.mapSystem.currentMap) {
6294
+ return { success: false, error: 'no map is loaded. Load one with pinepaper_map {action: "load"} first.' };
6295
+ }
6296
+ // canvasToGeo takes ONE [x, y] argument; (x, y) handed it a bare number.
6297
+ const coord = typeof app.mapSystem.canvasToGeo === 'function' ? app.mapSystem.canvasToGeo([${validated.x}, ${validated.y}]) : null;
6298
+ // The hit test the studio's own hover uses (getRegionAtPoint never existed).
6299
+ const f = app.mapSystem._findFeatureAtCanvasPoint(${validated.x}, ${validated.y});
6300
+ if (!f) return { success: true, regionId: null, coordinate: coord, message: 'no region at this point (off the map, or ocean)' };
6301
+ const p = f.properties || {};
6302
+ return { success: true, regionId: f._mapId !== undefined ? f._mapId : (p.id || p.ISO_A3 || p.name || null),
6303
+ regionName: f._mapName || p.name || p.NAME || null, coordinate: coord, properties: p };
6062
6304
  } catch (error) {
6063
6305
  return { success: false, error: error.message };
6064
6306
  }
@@ -6111,7 +6353,8 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6111
6353
  const result = app.mapSystem.animateRegionsWave({
6112
6354
  duration: ${validated.duration || 10},
6113
6355
  loop: ${validated.loop !== false},
6114
- colors: ${colorsStr},
6356
+ colors: ${colorsStr},${validated.waveSpeed !== undefined ? `
6357
+ waveSpeed: ${validated.waveSpeed},` : ''}
6115
6358
  waveDirection: '${validated.waveDirection || 'horizontal'}'
6116
6359
  });
6117
6360
  return { success: true, animatedRegions: result?.animatedRegions || [], totalRegions: result?.totalRegions || 0 };
@@ -6277,9 +6520,17 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6277
6520
  return { success: false, error: 'Map system not available' };
6278
6521
  }
6279
6522
 
6280
- try {
6281
- const result = app.mapSystem.getHighlightedRegions();
6282
- return { highlighted: result?.highlighted || [], count: result?.count || 0 };
6523
+ try {${this.mapRegionReadbackJs()}
6524
+ // getHighlightedRegions returns an ARRAY of {regionId, name, path}; this
6525
+ // read result.highlighted off it and always answered [] (gate D51). It also
6526
+ // walks regionPaths, empty on a rasterised map, so the override channel is
6527
+ // read as well.
6528
+ const __byId = new Map();
6529
+ (app.mapSystem.getHighlightedRegions() || []).forEach(function (r) { __byId.set(r.regionId, { regionId: r.regionId, name: r.name }); });
6530
+ __overridden().forEach(function (r) { __byId.set(r.regionId, r); });
6531
+ const __list = Array.from(__byId.values());
6532
+ return { success: true, highlighted: __list, count: __list.length,
6533
+ note: 'highlight and apply_colors share one colour channel, so this lists every region either has coloured.' };
6283
6534
  } catch (error) {
6284
6535
  return { success: false, error: error.message };
6285
6536
  }
@@ -6481,6 +6732,87 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6481
6732
  /**
6482
6733
  * Generate code to export the complete scene state
6483
6734
  */
6735
+ /**
6736
+ * pinepaper_place_on_surface (FxTool #56): app.placeOnSurface corner-pins
6737
+ * the UI into the photo's screen, mattes it so a hand stays in front, and
6738
+ * BAKES it into the photo's pixels. Async. Only plain fields come back.
6739
+ */
6740
+ generatePlaceOnSurface(input) {
6741
+ // Named, not spread: a spread makes every field look consumed, which is
6742
+ // how an accepted-and-ignored option hides (the guard test reads these).
6743
+ const opts = {};
6744
+ if (input.quad !== undefined)
6745
+ opts.quad = input.quad;
6746
+ if (input.quadSpace !== undefined)
6747
+ opts.quadSpace = input.quadSpace;
6748
+ if (input.occlusion !== undefined)
6749
+ opts.occlusion = input.occlusion;
6750
+ if (input.key !== undefined)
6751
+ opts.key = input.key;
6752
+ if (input.erode !== undefined)
6753
+ opts.erode = input.erode;
6754
+ if (input.matteBlur !== undefined)
6755
+ opts.matteBlur = input.matteBlur;
6756
+ if (input.soften !== undefined)
6757
+ opts.soften = input.soften;
6758
+ if (input.glare !== undefined)
6759
+ opts.glare = input.glare;
6760
+ if (input.spill !== undefined)
6761
+ opts.spill = input.spill;
6762
+ if (input.spillRadius !== undefined)
6763
+ opts.spillRadius = input.spillRadius;
6764
+ if (input.tint !== undefined)
6765
+ opts.tint = input.tint;
6766
+ if (input.slot !== undefined)
6767
+ opts.slot = input.slot;
6768
+ if (input.hideSource !== undefined)
6769
+ opts.hideSource = input.hideSource;
6770
+ const { photoId, sourceId } = input;
6771
+ return `
6772
+ // Place a UI onto a screen in a photo
6773
+ (async function() {
6774
+ if (typeof app.placeOnSurface !== 'function') { return { success: false, error: 'app.placeOnSurface unavailable — update FxTool' }; }
6775
+ const r = await app.placeOnSurface(${JSON.stringify(photoId)}, ${JSON.stringify(sourceId)}, ${JSON.stringify(opts)});
6776
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the UI could not be placed' }; }
6777
+ return { success: true, photoId: r.id, slot: r.slot, quad: r.quad, detected: r.detected || null,
6778
+ key: r.key, matteCoverage: r.matteCoverage, ...(r.lostSlots && r.lostSlots.length ? { lostSlots: r.lostSlots } : {}) };
6779
+ })();`.trim();
6780
+ }
6781
+ /**
6782
+ * A RESTORABLE project document (D28): app.captureProjectDocument, the
6783
+ * engine's own durable format (core/ProjectDocument.js), which carries
6784
+ * relations, rigging, connectors, scene chains and animation config.
6785
+ * NOT exportProjectJSON: the engine documents that one as a bare Paper tree
6786
+ * that drops roughly half of a non-trivial scene.
6787
+ */
6788
+ generateCaptureProject(name) {
6789
+ return `
6790
+ // Capture a restorable project document
6791
+ (function() {
6792
+ if (typeof app.captureProjectDocument !== 'function') { return { success: false, error: 'app.captureProjectDocument unavailable — update FxTool' }; }
6793
+ ${SCENE_COUNTS_JS}
6794
+ const doc = app.captureProjectDocument(${JSON.stringify(name ? { name } : {})});
6795
+ return { success: true, json: JSON.stringify(doc), counts: __ppSceneCounts() };
6796
+ })();`.trim();
6797
+ }
6798
+ /** Load a staged project document (window.__ppStage.projectDoc), replacing the scene. */
6799
+ generateLoadProject(strict) {
6800
+ return `
6801
+ // Load a project document, replacing the current scene
6802
+ (function() {
6803
+ if (typeof app.loadProjectDocument !== 'function') { return { success: false, error: 'app.loadProjectDocument unavailable — update FxTool' }; }
6804
+ ${SCENE_COUNTS_JS}
6805
+ const text = window.__ppStage && window.__ppStage.projectDoc;
6806
+ if (typeof text !== 'string' || !text) { return { success: false, error: 'no project document was staged' }; }
6807
+ // Second guard, in the page: nothing but a project document reaches the loader.
6808
+ let __doc = null;
6809
+ try { __doc = JSON.parse(text); } catch (e) { return { success: false, error: 'not a PinePaper scene file (not JSON); nothing was changed' }; }
6810
+ if (!__doc || __doc.kind !== 'pinepaper.project') { return { success: false, error: 'not a PinePaper scene file (no kind "pinepaper.project"); nothing was changed' }; }
6811
+ const r = app.loadProjectDocument(__doc, { strict: ${strict ? 'true' : 'false'} });
6812
+ 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) || [] }; }
6813
+ return { success: true, counts: __ppSceneCounts(), warnings: r.warnings || [], meta: r.meta || null };
6814
+ })();`.trim();
6815
+ }
6484
6816
  generateExportScene() {
6485
6817
  return `
6486
6818
  // Export complete scene state
@@ -6592,7 +6924,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6592
6924
  backgroundColor: backgroundColor,
6593
6925
  canvasSize: canvasSize,
6594
6926
  itemCount: items.length,
6595
- relationCount: relations.length
6927
+ relationCount: relations.length,
6928
+ // NOT A BACKUP (D28). This summary carries positions and bounds, not
6929
+ // keyframe tracks, rigging or scene chains: a scene saved this way and
6930
+ // rebuilt from it has no motion. Said in the result, not only in docs.
6931
+ 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
6932
  };
6597
6933
  } catch (error) {
6598
6934
  return { success: false, error: error.message };
@@ -6693,29 +7029,32 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6693
7029
  try {
6694
7030
  // Import the Paper.js item from JSON
6695
7031
  const item = paper.project.importJSON(${itemJsonStr});
6696
- if (!item) {
6697
- return { success: false, error: 'Failed to import item from JSON' };
7032
+ // importJSON accepts anything: { bogus: true } came back as an object with
7033
+ // no bounds and was registered as an item (found by the 1.6.19 gate). Only
7034
+ // a real Paper item with geometry may be registered.
7035
+ if (!item || !(item instanceof paper.Item) || !item.bounds) {
7036
+ try { if (item && typeof item.remove === 'function') item.remove(); } catch (e) {}
7037
+ return { success: false, error: 'itemJson is not a Paper.js item (pass the output of item.exportJSON())' };
7038
+ }
7039
+
7040
+ // REGISTER THROUGH THE ENGINE (gate H1, 1.6.19). This used to call
7041
+ // app.itemRegistry.set(), which does not exist ("not a function", nothing
7042
+ // registered), with an id invented from .size (undefined, so 'item_NaN',
7043
+ // or a collision), and it moved every item into the TEXT group. The
7044
+ // registry's own register() mints the id, never steals a live one, treats
7045
+ // re-registration as an update, and returns the id it assigned.
7046
+ // importJSON already put the item in the active layer.
7047
+ if (!app.itemRegistry || typeof app.itemRegistry.register !== 'function') {
7048
+ try { item.remove(); } catch (e) {}
7049
+ return { success: false, error: 'app.itemRegistry.register unavailable — update FxTool' };
6698
7050
  }
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
7051
  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);
7052
+ const itemId = app.itemRegistry.register(item, ${JSON.stringify(input.itemType)}, customProps, 'import');
7053
+ if (!itemId) {
7054
+ try { item.remove(); } catch (e) {}
7055
+ return { success: false, error: 'the registry refused the item' };
6718
7056
  }
7057
+ try { if (paper.view) paper.view.update(); } catch (e) {}
6719
7058
 
6720
7059
  return {
6721
7060
  success: true,
@@ -6997,7 +7336,9 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6997
7336
  // Get available mask types
6998
7337
  (function() {
6999
7338
  return {
7000
- maskTypes: ['rectangle', 'circle', 'ellipse', 'star', 'triangle', 'hexagon', 'heart', 'rounded', 'custom']
7339
+ // From the enum the mask tools validate against: this listed 'custom',
7340
+ // which both refuse (1.6.19 gate).
7341
+ maskTypes: ${JSON.stringify(MaskTypeSchema.options)}
7001
7342
  };
7002
7343
  })();
7003
7344
  `.trim();
@@ -7007,14 +7348,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7007
7348
  // Get available mask animation presets
7008
7349
  (function() {
7009
7350
  return {
7010
- animations: [
7011
- 'wipeLeft', 'wipeRight', 'wipeUp', 'wipeDown',
7012
- 'iris', 'irisOut',
7013
- 'star', 'heart',
7014
- 'curtainHorizontal', 'curtainVertical', 'cinematic',
7015
- 'diagonalWipe',
7016
- 'revealUp', 'revealDown'
7017
- ]
7351
+ animations: ${JSON.stringify(MaskPresetSchema.options)}
7018
7352
  };
7019
7353
  })();
7020
7354
  `.trim();
@@ -7028,10 +7362,12 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7028
7362
  const categoryFilter = category ? `'${category}'` : 'null';
7029
7363
  return `
7030
7364
  // List available templates
7031
- (function() {
7365
+ (async function() {
7032
7366
  if (!app.templateManager) {
7033
7367
  return { error: 'Template manager not available. Make sure PinePaper Studio is loaded.' };
7034
7368
  }
7369
+ // The built-in catalogue loads lazily; a fresh page listed none (1.6.19 gate).
7370
+ try { if (app.templateManager._ensureTemplateData) await app.templateManager._ensureTemplateData(); } catch (e) {}
7035
7371
  const allTemplates = app.templateManager.getAllTemplates();
7036
7372
  const category = ${categoryFilter};
7037
7373
  const filtered = category
@@ -7056,8 +7392,25 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7056
7392
  if (!app.templateManager) {
7057
7393
  return { error: 'Template manager not available. Make sure PinePaper Studio is loaded.' };
7058
7394
  }
7395
+ // AN UNKNOWN ID IS AN ERROR, NOT A SUCCESS (gate C1, 1.6.19). loadTemplate
7396
+ // on an id that does not exist changed nothing and this still reported
7397
+ // "Template applied successfully"; the description's own example id
7398
+ // ('solar-system') was one of them. The nearest real ids are offered.
7399
+ const __tid = ${JSON.stringify(templateId)};
7400
+ // The built-in catalogue loads LAZILY: on a fresh page getAllTemplates() is
7401
+ // [] until it does, which made every id look unknown. Load it first, as
7402
+ // loadTemplate itself does, and count the cloud templates it falls back to.
7403
+ try { if (app.templateManager._ensureTemplateData) await app.templateManager._ensureTemplateData(); } catch (e) {}
7404
+ const __cloud = (app.templateManager.cloudSync && app.templateManager.cloudSync.cloudTemplates) || [];
7405
+ const __all = app.templateManager.getAllTemplates().concat(__cloud.filter(Boolean));
7406
+ if (__all.length && !__all.some(function(t) { return t && t.id === __tid; })) {
7407
+ 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]; };
7408
+ 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) }; })
7409
+ .sort(function(a, b) { return a.d - b.d; }).slice(0, 5).map(function(x) { return x.id; });
7410
+ return { success: false, error: 'no template "' + __tid + '". Nearest: ' + __near.join(', ') + '. List them with listOnly: true.' };
7411
+ }
7059
7412
  try {
7060
- await app.templateManager.loadTemplate('${templateId}', true);
7413
+ await app.templateManager.loadTemplate(__tid, true);
7061
7414
 
7062
7415
  // DID THE CLIPPED CHARACTER PARTS SURVIVE?
7063
7416
  //
@@ -7714,7 +8067,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7714
8067
  for (const id of ids) {
7715
8068
  const entry = app.itemRegistry.get(id);
7716
8069
  if (entry && entry.item) {
7717
- ${mode === 'remove' ? 'entry.item.selected = false;' : 'app.select(entry.item);'}
8070
+ ${mode === 'remove' ? 'entry.item.selected = false;' : 'app.select(entry.item, false);'}
7718
8071
  selected.push(id);
7719
8072
  }
7720
8073
  }
@@ -7725,8 +8078,9 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7725
8078
  return `
7726
8079
  // Select all items
7727
8080
  (function() {
8081
+ ${REG_ID_JS}
7728
8082
  app.selectAll();
7729
- const items = app.getSelectedItems().map(i => i.data?.itemId || i.name || i.id);
8083
+ const items = app.getSelectedItems().map(i => __ppRegId(i)).filter(Boolean);
7730
8084
  return { success: true, action: 'select_all', count: items.length, items };
7731
8085
  })();`.trim();
7732
8086
  case 'deselect_all':
@@ -7740,8 +8094,9 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7740
8094
  return `
7741
8095
  // Get current selection
7742
8096
  (function() {
8097
+ ${REG_ID_JS}
7743
8098
  const items = app.getSelectedItems().map(i => ({
7744
- itemId: i.data?.itemId || i.name || i.id,
8099
+ itemId: __ppRegId(i),
7745
8100
  type: i.data?.itemType || i.className,
7746
8101
  bounds: i.bounds ? { x: i.bounds.x, y: i.bounds.y, width: i.bounds.width, height: i.bounds.height } : null
7747
8102
  }));
@@ -7751,8 +8106,9 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7751
8106
  return `
7752
8107
  // Delete selected items
7753
8108
  (function() {
8109
+ ${REG_ID_JS}
7754
8110
  const selection = app.getSelectedItems();
7755
- const deleted = selection.map(i => i.data?.itemId || i.name || i.id);
8111
+ const deleted = selection.map(i => __ppRegId(i)).filter(Boolean);
7756
8112
  app.deleteSelected();
7757
8113
  return { success: true, action: 'delete_selected', deleted, count: deleted.length };
7758
8114
  })();`.trim();
@@ -7931,7 +8287,7 @@ ${loads}
7931
8287
  case 'lineage': return this._facadeCall('getLineage', id, 'Provenance: lineage');
7932
8288
  case 'dependents': return this._facadeCall('getDependents', id, 'Provenance: dependents');
7933
8289
  case 'record':
7934
- return this._facadeCall('recordLineage', `${id}, ${JSON.stringify(input.kind || 'derived')}, ${JSON.stringify(input.sourceRef || '')}, ${JSON.stringify(input.meta || {})}`, 'Provenance: record lineage');
8290
+ return this._facadeCall('recordLineage', `${id}, ${JSON.stringify(input.kind || 'derivedFrom')}, ${JSON.stringify(input.sourceRef || '')}, ${JSON.stringify(input.meta || {})}`, 'Provenance: record lineage');
7935
8291
  }
7936
8292
  }
7937
8293
  generateCompose(input) {
@@ -7953,27 +8309,46 @@ ${loads}
7953
8309
  // vector and audio halves of composeCollage were unreachable through
7954
8310
  // MCP, so agents converged on the four static patterns the description
7955
8311
  // 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');
8312
+ {
8313
+ const apply = this._facadeCall('composeCollage', `${JSON.stringify(input.pattern || '')}, ${JSON.stringify(input.itemIds || [])}, ${JSON.stringify({
8314
+ ...(input.treatment ? { treatment: input.treatment } : {}),
8315
+ ...(input.applyCamera === false ? { applyCamera: false } : {}),
8316
+ ...(input.loop ? { loop: true } : {}),
8317
+ ...(input.craft ? { craft: input.craft } : {}),
8318
+ ...(input.reveal !== undefined ? { reveal: input.reveal } : {}),
8319
+ ...(input.revealOptions ? { revealOptions: input.revealOptions } : {}),
8320
+ ...(input.style ? { style: input.style } : {}),
8321
+ ...(input.text ? { text: input.text } : {}),
8322
+ ...(input.assets ? { assets: input.assets } : {}),
8323
+ ...(input.audio ? { audio: input.audio } : {}),
8324
+ ...(input.grid ? { grid: true } : {}),
8325
+ // The design axes. A register resolves the craft (gutter, margin,
8326
+ // hue budget, type scale); a medium says what makes the marks and
8327
+ // is REFUSED when a composition cannot be rendered in it.
8328
+ ...(input.register ? { register: input.register } : {}),
8329
+ ...(input.level !== undefined ? { level: input.level } : {}),
8330
+ ...(input.medium ? { medium: input.medium } : {}),
8331
+ ...(input.stitch ? { stitch: input.stitch } : {}),
8332
+ ...(input.stitchBudget !== undefined ? { stitchBudget: input.stitchBudget } : {}),
8333
+ })}`, 'Compose: apply pattern');
8334
+ ;
8335
+ if (typeof input.reveal !== 'string' || !input.reveal)
8336
+ return apply;
8337
+ // CHECK THE REVEAL FIRST (gate C3). composeCollage lays the items out
8338
+ // and wires their relations BEFORE it reaches an unknown reveal, so a
8339
+ // typo errored with the grid already applied and nothing reverted.
8340
+ return `
8341
+ // Compose: check the reveal, then apply
8342
+ (async function() {
8343
+ if (typeof app.listReveals === 'function') {
8344
+ const __keys = (app.listReveals() || []).map(function (r) { return r && r.key; });
8345
+ if (__keys.length && __keys.indexOf(${JSON.stringify(input.reveal)}) === -1) {
8346
+ return { success: false, error: 'unknown reveal ' + ${JSON.stringify(JSON.stringify(input.reveal))} + ' — known: ' + __keys.join(', ') + '. Nothing was changed.' };
8347
+ }
8348
+ }
8349
+ return await ${apply.replace(/^\/\/[^\n]*\n/, '').replace(/;\s*$/, '')};
8350
+ })();`.trim();
8351
+ }
7977
8352
  }
7978
8353
  }
7979
8354
  generateAudioBeats(input) {
@@ -8624,16 +8999,28 @@ ${resolve}
8624
8999
  return `
8625
9000
  // Clear background
8626
9001
  (function() {
9002
+ // clearBackground() removes generated patterns, drawings and the image, but
9003
+ // not the colour; the tool promises "remove entirely" and left #1e293b
9004
+ // behind (gate C6, 1.6.19). The colour goes too, to transparent.
8627
9005
  app.clearBackground();
8628
- return { success: true, action: 'clear' };
9006
+ if (typeof app.setBackgroundColor === 'function') app.setBackgroundColor('transparent');
9007
+ return { success: true, action: 'clear', removed: ['pattern', 'drawing', 'image', 'color'] };
8629
9008
  })();`.trim();
8630
9009
  case 'get':
8631
9010
  return `
8632
9011
  // Get background info
8633
9012
  (function() {
8634
- // FxTool exposes getBackgroundMode() returning the mode string only.
9013
+ // getBackgroundMode() returns only the mode, so 'color' came back with no
9014
+ // colour (gate C6, 1.6.19). The colour is read from the canvas, as hex.
8635
9015
  const mode = app.getBackgroundMode ? app.getBackgroundMode() : null;
8636
- return { success: true, action: 'get', mode };
9016
+ const css = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || '';
9017
+ // No regex: this is a template literal, and \\( or \\s here reach the page
9018
+ // without their backslash (that was the #NaN293b).
9019
+ const open = css.indexOf('('), close = css.indexOf(')');
9020
+ let color = css || null;
9021
+ 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(''); }
9022
+ const patterns = app.patternGroup && app.patternGroup.children ? app.patternGroup.children.length : 0;
9023
+ return { success: true, action: 'get', mode, color, hasPattern: patterns > 0, hasImage: !!(app.config && app.config.backgroundImage) };
8637
9024
  })();`.trim();
8638
9025
  default:
8639
9026
  return `(function() { return { error: 'Unknown background action: ${input.action}' }; })();`;
@@ -8669,13 +9056,14 @@ ${resolve}
8669
9056
  return `
8670
9057
  // Hit test at point
8671
9058
  (function() {
9059
+ ${REG_ID_JS}
8672
9060
  const point = new paper.Point(${input.x ?? 0}, ${input.y ?? 0});
8673
9061
  ${all
8674
9062
  ? `const results = app.hitTestAll(point, { tolerance: ${tolerance} });
8675
9063
  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 }
9064
+ itemId: __ppRegId(r.item),
9065
+ type: r.item?.data?.type || r.item?.className,
9066
+ point: r.point ? { x: r.point.x, y: r.point.y } : null
8679
9067
  }));
8680
9068
  return { success: true, action: 'hit_test', x: ${input.x ?? 0}, y: ${input.y ?? 0}, hits, count: hits.length };`
8681
9069
  : `const result = app.hitTest(point, { tolerance: ${tolerance} });
@@ -8683,7 +9071,7 @@ ${resolve}
8683
9071
  return {
8684
9072
  success: true, action: 'hit_test',
8685
9073
  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 }
9074
+ 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
9075
  };`}
8688
9076
  })();`.trim();
8689
9077
  }
@@ -9083,12 +9471,47 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9083
9471
  ${guard}
9084
9472
  const entry = app.itemRegistry.get(${itemIdStr});
9085
9473
  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} };
9474
+ // The engine's remove returns nothing, and this used to echo its inputs, so
9475
+ // a wrong behaviorId still "succeeded" (gate run 2). Count what went.
9476
+ 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; }); };
9477
+ const __before = __list();
9478
+ const __bid = ${behaviorIdStr};
9479
+ if (__bid && __before.indexOf(__bid) === -1) {
9480
+ 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.' };
9481
+ }
9482
+ if (__bid) app.interactionSystem.removeContinuousBehavior(entry.item, __bid);
9483
+ else {
9484
+ // "Remove all" means the behaviors a caller ADDED. The engine's own
9485
+ // selectable / draggable go too with a bare remove, which left the item
9486
+ // impossible to select or drag (gate run 3, N4).
9487
+ const __sys = ['selectable', 'draggable'];
9488
+ const __m = app.interactionSystem.continuousBehaviors;
9489
+ ((__m && __m.get ? __m.get(${itemIdStr}) : null) || []).slice()
9490
+ .filter(function (b) { return __sys.indexOf(b.type) === -1; })
9491
+ .forEach(function (b) { app.interactionSystem.removeContinuousBehavior(entry.item, b.id); });
9492
+ }
9493
+ const __after = __list();
9494
+ const removed = __before.filter(function (id) { return __after.indexOf(id) === -1; });
9495
+ 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
9496
  })();`.trim();
9089
9497
  }
9090
9498
  case 'trigger_action': {
9091
- const params = JSON.stringify(input.params || {});
9499
+ // The engine's handlers read params.items (ids) for show / hide /
9500
+ // animate / stopAnimation, and params.points for incrementScore. The
9501
+ // tool documented neither, so { itemId } hid nothing and { amount: 5 }
9502
+ // scored 1 (gate D13, 1.6.19). The natural names are accepted too.
9503
+ const raw = { ...(input.params || {}) };
9504
+ if (raw.items === undefined) {
9505
+ const one = raw.itemId ?? raw.targetItemId;
9506
+ const many = raw.itemIds;
9507
+ if (Array.isArray(many))
9508
+ raw.items = many;
9509
+ else if (typeof one === 'string')
9510
+ raw.items = [one];
9511
+ }
9512
+ if (input.actionType === 'incrementScore' && raw.points === undefined && typeof raw.amount === 'number')
9513
+ raw.points = raw.amount;
9514
+ const params = JSON.stringify(raw);
9092
9515
  return `
9093
9516
  // Trigger interaction action
9094
9517
  (function() {
@@ -9460,8 +9883,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9460
9883
  // Toggle grid
9461
9884
  (function() {
9462
9885
  ${guard}
9463
- // showGrid()/hideGrid(), not a boolean setter — setGridVisible never existed.
9464
- app.measurementSystem[${enabled} ? 'showGrid' : 'hideGrid']();
9886
+ // setUnitGridEnabled shows/hides the grid AND records the setting that
9887
+ // set_snap depends on; showGrid() alone left snapping inert (1.6.19 gate).
9888
+ if (typeof app.setUnitGridEnabled === 'function') app.setUnitGridEnabled(${enabled});
9889
+ else app.measurementSystem[${enabled} ? 'showGrid' : 'hideGrid']();
9465
9890
  return { success: true, action: 'set_grid', enabled: ${enabled} };
9466
9891
  })();`.trim();
9467
9892
  }
@@ -9480,17 +9905,18 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9480
9905
  })();`.trim();
9481
9906
  }
9482
9907
  case 'set_snap': {
9483
- // `enabled` is deliberately unread: there is no mode to set either way.
9908
+ // The engine has setSnapToUnitEnabled now; this refused, saying it had
9909
+ // never existed (1.6.19 gate). It is an EDITOR setting: it snaps a
9910
+ // drag or resize to the unit grid's ticks, and does nothing while that
9911
+ // grid is off. Tool-placed items are not snapped, and the result says so.
9484
9912
  return `
9485
- // Snap to grid — refused, see below
9913
+ // Snap to unit grid (drag / resize in the studio)
9486
9914
  (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.' };
9915
+ if (typeof app.setSnapToUnitEnabled !== 'function') { return { success: false, action: 'set_snap', error: 'this studio has no snap-to-unit setting; update FxTool' }; }
9916
+ const enabled = app.setSnapToUnitEnabled(${input.enabled !== false});
9917
+ const gridOn = typeof app.getUnitGridEnabled === 'function' ? app.getUnitGridEnabled() : null;
9918
+ return { success: true, action: 'set_snap', enabled: enabled, unitGrid: gridOn,
9919
+ 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
9920
  })();`.trim();
9495
9921
  }
9496
9922
  default:
@@ -9568,6 +9994,30 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9568
9994
  * Extract the best-matching detected region from an image as a NEW item (FxTool
9569
9995
  * ImageWorkflow.extractObject). Async (runs detection first).
9570
9996
  */
9997
+ /**
9998
+ * pinepaper_styled_scene (FxTool K2). app.styledScene answers {ok:false,
9999
+ * error} rather than rejecting, so the verdict is read, not assumed. The
10000
+ * result carries the duration to export for: the scene item lives that long,
10001
+ * and an export left at its own default would cut it short or pad it.
10002
+ */
10003
+ generateStyledScene(input) {
10004
+ const opts = {
10005
+ ...(input.style !== undefined ? { style: input.style } : {}),
10006
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
10007
+ ...(input.id !== undefined ? { id: input.id } : {}),
10008
+ };
10009
+ return `
10010
+ // Styled scene${input.style ? ` (${input.style.replace(/[\r\n]/g, ' ')})` : ''}
10011
+ (async function() {
10012
+ if (typeof app.styledScene !== 'function') { return { success: false, error: 'app.styledScene unavailable — update FxTool' }; }
10013
+ const r = await app.styledScene(${JSON.stringify(input.spec ?? {})}, ${JSON.stringify(opts)});
10014
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the styled scene was refused' }; }
10015
+ const notes = ['export for ' + r.duration + ' s (agent_export duration: ' + r.duration + '): the scene runs that long'];
10016
+ if (r.style === 'dither' || r.style === 'ascii') notes.push(r.style + ' needs a Chrome renderer (WebKit ignores the canvas filter it uses)');
10017
+ notes.push('the scene redraws every exported frame; after a page reload it is a still until styled_scene is called again');
10018
+ return { success: true, itemId: r.id, style: r.style, width: r.width, height: r.height, duration: r.duration, firstFrameMs: r.firstFrameMs, notes: notes };
10019
+ })();`.trim();
10020
+ }
9571
10021
  generateExtractObject(input) {
9572
10022
  const args = JSON.stringify({
9573
10023
  ...(input.label !== undefined ? { label: input.label } : {}),
@@ -9584,6 +10034,16 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9584
10034
  }
9585
10035
  try {
9586
10036
  const res = await app.extractObject(${args});
10037
+ // A numeric itemId is Paper's own id: the engine's register fallback did
10038
+ // not take, so the new item was unaddressable and uncounted (gate run 2:
10039
+ // itemId 226, itemsCreated 0). Register it here and hand back that id.
10040
+ if (res && res.ok && typeof res.itemId === 'number' && typeof paper !== 'undefined' && paper.project) {
10041
+ const __n = res.itemId;
10042
+ const __it = paper.project.getItem({ match: function (i) { return i.id === __n; } });
10043
+ const __rid = __it && app.itemRegistry && app.itemRegistry.register ? app.itemRegistry.register(__it, 'image', { source: 'extract' }, 'import') : null;
10044
+ if (typeof __rid === 'string') res.itemId = __rid;
10045
+ else return { success: false, error: 'the object was extracted but could not be registered, so it has no usable id (Paper id ' + __n + ')' };
10046
+ }
9587
10047
  const out = Object.assign({ success: !!(res && res.ok) }, res || {});
9588
10048
  if (!out.success && typeof out.error === 'string') out.error = (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(out.error);
9589
10049
  return out;
@@ -9971,12 +10431,26 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
9971
10431
  // which get the box loop below and a note saying so.
9972
10432
  const doc = ${docJson};
9973
10433
  if (typeof app.instantiateOntology === 'function') {
9974
- const r = await app.instantiateOntology(doc, ${engineOptsJson});
10434
+ // THE LIVE CANVAS, unless one was given: the engine lays out for 1080x1080
10435
+ // otherwise, which put items at (540,540) on a 640x360 canvas (gate run 3).
10436
+ const __opts = ${engineOptsJson};
10437
+ if (!__opts.canvas && app.getCanvasSize) { const __c = app.getCanvasSize(); if (__c && __c.width > 0) __opts.canvas = { width: __c.width, height: __c.height }; }
10438
+ // REGISTRY IDS. The engine returns the DOCUMENT's ids ("bar"), which no
10439
+ // other tool resolves; the new registry entries are found by diffing.
10440
+ const __all = function () { return (app.itemRegistry && app.itemRegistry.getAll) ? app.itemRegistry.getAll() : []; };
10441
+ const __before = new Set(__all().map(function (e) { return e.itemId; }));
10442
+ const r = await app.instantiateOntology(doc, __opts);
9975
10443
  const diags = r.diagnostics || [];
10444
+ 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; });
10445
+ const docIds = r.itemIds || [];
10446
+ const idMap = {};
10447
+ if (__new.length === docIds.length) docIds.forEach(function (d, i) { idMap[d] = __new[i]; });
9976
10448
  return {
9977
10449
  success: !diags.some(function(d) { return d && d.level === 'error'; }) && !r.imagesFailed,
9978
10450
  engine: 'studio',
9979
- itemIds: r.itemIds, itemCount: r.itemIds.length,
10451
+ itemIds: __new, itemCount: __new.length, docIds: docIds,
10452
+ ...(Object.keys(idMap).length ? { idMap: idMap } : { note: 'document ids could not be paired with registry ids one to one; use itemIds' }),
10453
+ canvas: __opts.canvas || null,
9980
10454
  // Every count the engine returns, not a chosen three. imagesFailed is the
9981
10455
  // one that matters most: a raster that never decoded leaves a scene that
9982
10456
  // looks built and is not, and dropping the count is how that becomes
@@ -10464,7 +10938,9 @@ ${guard}
10464
10938
  // item — so reading .data.id off the answer returned no id, every time. The
10465
10939
  // group is registered only on one of its two branches; the other is
10466
10940
  // registered here so the caller always gets an addressable id.
10467
- const r = app.createCursiveText(${S(input.text)}, ${S(input.cursiveOptions ?? {})});
10941
+ // nib goes to the engine as a boolean or a tuning object. A STRING is
10942
+ // truthy there, so 'monoline' would have selected the broad nib (D31).
10943
+ const r = app.createCursiveText(${S(input.text)}, ${S(cursiveEngineOptions(input.cursiveOptions))});
10468
10944
  const g = r && (r.group || (r.data ? r : null));
10469
10945
  if (!g) { return { success: false, error: 'the cursive text produced no path' }; }
10470
10946
  let itemId = g.data && (g.data.id || g.data.registryId);
@@ -10505,7 +10981,11 @@ ${guard}
10505
10981
  // would otherwise read as "the conversion did nothing to your text".
10506
10982
  const r = app.convertTextToCollage(item, ${S(input.text ?? null)} || item.content, ${S(input.collageOptions ?? {})});
10507
10983
  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 };
10984
+ // A summary, not the collage's Paper group (that was a raw dump, gate run 3).
10985
+ ${REG_ID_JS}
10986
+ const __g = (r && (r.group || r.item)) || (r && typeof r.className === 'string' ? r : null);
10987
+ return { success: true, itemId: __g ? __ppRegId(__g) : null, letters: __g && __g.children ? __g.children.length : undefined,
10988
+ bounds: __g && __g.bounds ? { x: __g.bounds.x, y: __g.bounds.y, width: __g.bounds.width, height: __g.bounds.height } : undefined };
10509
10989
  })();`.trim();
10510
10990
  }
10511
10991
  }
@@ -10520,6 +11000,117 @@ ${guard}
10520
11000
  * only ids/count/duration are forwarded. `ids` is the whole handle set the
10521
11001
  * caller now has, since the source item is gone.
10522
11002
  */
11003
+ /**
11004
+ * pinepaper_relight — FxTool D5's relight post pass over the 3D world layer.
11005
+ * The rig is not saved with the project, GIF / APNG are not relit, and it
11006
+ * costs ~0.42 s per 1080p frame on the CPU, so a set says what an export
11007
+ * will pay.
11008
+ */
11009
+ generateRelight(input) {
11010
+ const S = (v) => JSON.stringify(v);
11011
+ 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.' }; }`;
11012
+ switch (input.action) {
11013
+ case 'set': {
11014
+ const spec = S({
11015
+ ...(input.lights !== undefined ? { lights: input.lights } : {}),
11016
+ ...(input.ambient !== undefined ? { ambient: input.ambient } : {}),
11017
+ ...(input.strength !== undefined ? { strength: input.strength } : {}),
11018
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
11019
+ ...(input.preview !== undefined ? { preview: input.preview } : {}),
11020
+ });
11021
+ return `
11022
+ // Relight: set the rig
11023
+ (function() {
11024
+ ${missing('setRelight')}
11025
+ const r = app.setRelight(${spec});
11026
+ if (!r || r.ok === false) { return { success: false, action: 'set', error: (r && ((r.errors && r.errors.join('; ')) || r.reason)) || 'the relight rig was refused' }; }
11027
+ // What an export will pay: ~418 ms per 1920x1080 frame, scaled by pixels.
11028
+ const __f = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11029
+ const __ms = Math.round(418 * (__f.width * __f.height) / (1920 * 1080));
11030
+ const res = { success: true, action: 'set', active: r.active, relight: r.relight,
11031
+ cost: { msPerFrame: __ms, frame: { width: __f.width, height: __f.height },
11032
+ tenSecondsAt30fps: Math.round(__ms * 300 / 1000) + ' s extra' },
11033
+ 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.' };
11034
+ if (r.reason) res.reason = r.reason;
11035
+ 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).';
11036
+ return res;
11037
+ })();`.trim();
11038
+ }
11039
+ case 'get':
11040
+ return `
11041
+ // Relight: the current rig
11042
+ (function() {
11043
+ ${missing('getRelight')}
11044
+ return Object.assign({ success: true, action: 'get' }, app.getRelight());
11045
+ })();`.trim();
11046
+ case 'clear':
11047
+ return `
11048
+ // Relight: remove the rig
11049
+ (function() {
11050
+ ${missing('clearRelight')}
11051
+ const r = app.clearRelight();
11052
+ return { success: true, action: 'clear', cleared: !!(r && r.cleared) };
11053
+ })();`.trim();
11054
+ case 'list_lights':
11055
+ return `
11056
+ // Relight: light types
11057
+ (function() {
11058
+ ${missing('listRelightLights')}
11059
+ return { success: true, action: 'list_lights', lights: app.listRelightLights() };
11060
+ })();`.trim();
11061
+ }
11062
+ }
11063
+ /**
11064
+ * pinepaper_shader_graph — FxTool D5's JSON pixel graphs. Applying to an
11065
+ * item makes a NEW image item and hides the source (one undo step);
11066
+ * applying to 'scene' returns the processed frame and changes nothing.
11067
+ */
11068
+ generateShaderGraph(input) {
11069
+ const S = (v) => JSON.stringify(v);
11070
+ const ref = input.graph ?? input.graphId;
11071
+ 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.' }; }`;
11072
+ const fail = `(r && ((r.errors && r.errors.join('; ')) || r.reason)) || 'the shader graph call failed'`;
11073
+ const body = (() => {
11074
+ switch (input.action) {
11075
+ case 'node_types': return ` return { success: true, action: 'node_types', nodeTypes: app.shaderGraph.nodeTypes() };`;
11076
+ case 'list': return ` const r = app.shaderGraph.list();\n return { success: true, action: 'list', graphs: (r && r.graphs) || [] };`;
11077
+ 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 };`;
11078
+ 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 };`;
11079
+ 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 };`;
11080
+ 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 };`;
11081
+ case 'apply': {
11082
+ const opts = S({ ...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}), ...(input.asItem !== undefined ? { asItem: input.asItem } : {}) });
11083
+ return ` if (typeof app.applyShaderGraph !== 'function') { return { success: false, action: 'apply', error: 'this studio has no app.applyShaderGraph — it predates FxTool D5.' }; }
11084
+ const r = await app.applyShaderGraph(${S(input.target)}, ${S(ref)}, ${opts});
11085
+ if (!r || !r.ok) return { success: false, action: 'apply', errors: (r && r.errors) || undefined, error: ${fail} };
11086
+ if (r.target === 'item') {
11087
+ if (!r.id) return { success: false, action: 'apply', error: 'the graph ran but made no item' };
11088
+ // The new item's OWN bounds: a bloom with reach (FxTool D60) is larger
11089
+ // than its source, centred on it, so the source's bounds are not these.
11090
+ const __ne = app.itemRegistry && typeof app.itemRegistry.get === 'function' ? app.itemRegistry.get(r.id) : null;
11091
+ const __ni = __ne && __ne.item;
11092
+ // An image item made from a data URL decodes ASYNCHRONOUSLY and has 0x0
11093
+ // bounds until it does (prod read {width:0, height:0}); wait for it.
11094
+ if (__ni && __ni.className === 'Raster' && !__ni.loaded && typeof __ni.once === 'function') {
11095
+ await new Promise(function (resolve) { const t = setTimeout(resolve, 5000); __ni.once('load', function () { clearTimeout(t); resolve(); }); });
11096
+ }
11097
+ const __nb = __ni && __ni.bounds && __ni.bounds.width > 0 ? __ni.bounds : null;
11098
+ 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,
11099
+ ...(__nb ? { bounds: { x: __nb.x, y: __nb.y, width: __nb.width, height: __nb.height } } : {}) };
11100
+ }
11101
+ const res = { success: true, action: 'apply', target: 'scene', graphId: r.graphId, width: r.width, height: r.height };
11102
+ 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.'; }
11103
+ return res;`;
11104
+ }
11105
+ }
11106
+ })();
11107
+ return `
11108
+ // Shader graph: ${input.action}
11109
+ (async function() {
11110
+ ${head}
11111
+ ${body}
11112
+ })();`.trim();
11113
+ }
10523
11114
  /**
10524
11115
  * The medium axis. `list_media` and `resolve` are READS — they exist so an
10525
11116
  * agent can find out what this build can honestly make before it promises a
@@ -10542,8 +11133,43 @@ ${guard}
10542
11133
  if (!r || r.ok === false) {
10543
11134
  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
11135
  }
11136
+ // ink / charcoal restyle IN PLACE (FxTool D32): the same item, marked with
11137
+ // data.brushStyle on its paths and texts. Read the mark back, so an item
11138
+ // the engine passed over is a failure rather than a success (gate D32).
11139
+ if (${S(input.medium)} === 'ink' || ${S(input.medium)} === 'charcoal') {
11140
+ const __e = app.itemRegistry && app.itemRegistry.get(r.id || ${S(input.itemId)});
11141
+ const __it = (__e && __e.item) || r.item;
11142
+ let __styled = 0;
11143
+ const __walk = function (n) {
11144
+ if (!n) return;
11145
+ if (n.data && n.data.brushStyle) __styled++;
11146
+ (n.children || []).forEach(__walk);
11147
+ };
11148
+ __walk(__it);
11149
+ if (!__styled) {
11150
+ 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.' };
11151
+ }
11152
+ 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 } : {}) };
11153
+ }
10545
11154
  return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.note ? { note: r.note } : {}) };
10546
11155
  })();`.trim();
11156
+ case 'sdf_stroke': {
11157
+ // FxTool D5: an SDF capsule stroke rendered to a bitmap and created as a
11158
+ // registered, undoable image item. CPU-backed.
11159
+ const opts = S({
11160
+ ...(input.width !== undefined ? { width: input.width } : {}),
11161
+ ...(input.softness !== undefined ? { softness: input.softness } : {}),
11162
+ ...(input.color !== undefined ? { color: input.color } : {}),
11163
+ ...(input.opacity !== undefined ? { opacity: input.opacity } : {}),
11164
+ ...(input.resolution !== undefined ? { resolution: input.resolution } : {}),
11165
+ ...(input.name !== undefined ? { name: input.name } : {}),
11166
+ });
11167
+ 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.' }; }
11168
+ const r = app.sdfBrushStroke(${S(input.points)}, ${opts});
11169
+ 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' }; }
11170
+ if (!r.id) { return { success: false, action: 'sdf_stroke', error: 'the stroke was drawn but returned no item id' }; }
11171
+ return { success: true, action: 'sdf_stroke', itemId: r.id, bounds: r.bounds, pixelSize: r.pixelSize, backend: r.backend };`);
11172
+ }
10547
11173
  case 'list_media':
10548
11174
  return wrap('Medium: list', ` if (typeof app.listDesignMedia !== 'function') { return { success: false, error: 'medium axis unavailable — update FxTool' }; }
10549
11175
  const media = app.listDesignMedia();
@@ -10792,14 +11418,60 @@ ${guard}
10792
11418
  // Import a layered character (decomposer output → role-bound parts; blink works out of the box)
10793
11419
  (async function() {
10794
11420
  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)})) };
11421
+ // The engine looks layers up by NORMALISED tag (lowercase, no dir/extension,
11422
+ // '_'/'-' collapsed to a space): parseSeeThroughManifest asks
11423
+ // images.has(normalizePartTag(tag)), then images.get(layer.tag). Its own
11424
+ // folder reader keys the Map that way; a wire object keyed 'eye_left' is never
11425
+ // found and every multi-word layer is skipped as "no image found".
11426
+ // Mirrors FxTool js/ontology/CharacterPartTaxonomy.js normalizePartTag.
11427
+ const norm = (t) => String(t).trim().toLowerCase().replace(/^.*[\\\\/]/, '').replace(/\\.(png|webp|tga|psd)$/i, '').replace(/[_\\-\\s]+/g, ' ').trim();
11428
+ // The taxonomy resolves DECOMPOSER tags (eyel, iridesr, eyebrowl, hairf) — not
11429
+ // the role tokens it emits. A layer tagged with a role token (eye_left,
11430
+ // pupil_right, hair_front) resolves to no role, so alias role tokens to the
11431
+ // decomposer tag that produces them.
11432
+ const SIDED = { eye: 'eye', pupil: 'irides', brow: 'eyebrow', eyelash: 'eyelash', ear: 'ears', hand: 'handwear' };
11433
+ const FLAT = { 'hair front': 'hairf', 'hair back': 'hairb', torso: 'topwear', hips: 'bottomwear', legs: 'legwear', feet: 'footwear', prop: 'objects' };
11434
+ const canon = (t) => {
11435
+ const k = norm(t);
11436
+ let m = /^(.+) (left|right|l|r)$/.exec(k);
11437
+ if (m && SIDED[m[1]] !== undefined) return SIDED[m[1]] + m[2][0];
11438
+ m = /^(left|right) (.+)$/.exec(k);
11439
+ if (m && SIDED[m[2]] !== undefined) return SIDED[m[2]] + m[1][0];
11440
+ return FLAT[k] || k;
11441
+ };
11442
+ const rawInfo = ${S(input.info)};
11443
+ const rawImages = ${S(input.images)};
11444
+ const partsKey = rawInfo.parts ? 'parts' : (rawInfo.tag2pinfo ? 'tag2pinfo' : null);
11445
+ const info = { ...rawInfo };
11446
+ const notes = [];
11447
+ if (partsKey && rawInfo[partsKey] && typeof rawInfo[partsKey] === 'object' && !Array.isArray(rawInfo[partsKey])) {
11448
+ info[partsKey] = {};
11449
+ for (const [t, v] of Object.entries(rawInfo[partsKey])) {
11450
+ const c = canon(t);
11451
+ if (info[partsKey][c]) { notes.push('layer "' + t + '" collides with another layer as "' + c + '" — kept the first'); continue; }
11452
+ info[partsKey][c] = v;
11453
+ }
11454
+ }
11455
+ const images = new Map();
11456
+ const suppliedBy = {}; // canonical tag → the caller's own key, for naming
11457
+ for (const [t, v] of Object.entries(rawImages)) {
11458
+ const c = canon(t);
11459
+ if (images.has(c)) { notes.push('image "' + t + '" collides with "' + suppliedBy[c] + '" — kept the first'); continue; }
11460
+ images.set(c, v); suppliedBy[c] = t;
11461
+ }
11462
+ const bundle = { info, images };
10798
11463
  const r = await app.importLayeredCharacter(bundle, ${opts});
10799
11464
  if (!r || !r.groupId) { return { success: false, error: (r && r.error) || 'layered character import failed' }; }
10800
11465
  // roles maps part roles → item ids; zero roles wired means the character
10801
11466
  // 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 || [] };
11467
+ const imported = new Set((r.parts || []).map((p) => p.tag));
11468
+ const skipped = Object.keys(suppliedBy).filter((c) => !imported.has(c)).map((c) => suppliedBy[c]);
11469
+ const unroled = (r.parts || []).filter((p) => !p.role).map((p) => suppliedBy[p.tag] || p.tag);
11470
+ const result = { groupId: r.groupId, parts: (r.parts || []).length, roles: r.roles || {}, rolesWired: Object.keys(r.roles || {}).length, skipped, unroled, warnings: [...notes, ...(r.warnings || [])] };
11471
+ // A supplied image that did not land is the caller's input dropped — not a
11472
+ // success. The group IS on the canvas, so hand back its id to fix or delete.
11473
+ 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)' }; }
11474
+ return { success: true, ...result };
10803
11475
  })();`.trim();
10804
11476
  }
10805
11477
  /**
@@ -10902,7 +11574,9 @@ ${needWorld}
10902
11574
  (function() {
10903
11575
  if (typeof app.addWorldActor !== 'function') { return { success: false, error: 'app.addWorldActor unavailable — update FxTool' }; }
10904
11576
  ${needWorld}
10905
- const id = app.addWorldActor(${actor});
11577
+ const __r = app.addWorldActor(${actor});
11578
+ // The engine may return the actor itself; the id is what later calls take (gate D5).
11579
+ const id = (__r && typeof __r === 'object') ? (__r.id || null) : __r;
10906
11580
  return id ? { success: true, actorId: id } : { success: false, error: 'actor not added — is the sprite id a canvas item?' };
10907
11581
  })();`.trim();
10908
11582
  }
@@ -10940,7 +11614,9 @@ ${needWorld}
10940
11614
  // World3D: place an object (y defaults to sitting on the terrain)
10941
11615
  (function() {
10942
11616
  ${needWorld}
10943
- const id = app.addWorldObject(${S(world3dColors(input.object))});
11617
+ const __o = app.addWorldObject(${S(world3dColors(input.object))});
11618
+ // addWorldObject returns the OBJECT; its id is what set/remove calls take (gate D5).
11619
+ const id = (__o && typeof __o === 'object') ? (__o.id || null) : __o;
10944
11620
  return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
10945
11621
  })();`.trim();
10946
11622
  case 'remove_object':
@@ -10955,7 +11631,10 @@ ${needWorld}
10955
11631
  // World3D: tear the world down — Paper's canvas is untouched (it was always its own layer)
10956
11632
  (function() {
10957
11633
  if (typeof app.removeWorld3D !== 'function') { return { success: false, error: 'app.removeWorld3D unavailable — update FxTool' }; }
10958
- return { success: !!app.removeWorld3D() };
11634
+ // removeWorld3D's return value is not a success flag; whether the world is
11635
+ // gone is (gate D5: it worked and reported failure).
11636
+ app.removeWorld3D();
11637
+ return { success: !app._world3d };
10959
11638
  })();`.trim();
10960
11639
  // --- Mesh authoring ---------------------------------------------------
10961
11640
  // extrudeToMesh / latheToMesh REFUSE by name when there is no world
@@ -11008,7 +11687,19 @@ ${needWorld}
11008
11687
  // World3D: every authored mesh on the stage
11009
11688
  (function() {
11010
11689
  if (typeof app.listWorldMeshes !== 'function') { return { success: false, error: 'app.listWorldMeshes unavailable — update FxTool' }; }
11011
- return { success: true, meshes: app.listWorldMeshes() };
11690
+ // Summarised (gate A, 1.6.19): one star returned over 10 KB, nearly all of it
11691
+ // collision and vertex arrays nobody asked for. Arrays longer than 16 numbers
11692
+ // become their length; everything else passes as is.
11693
+ const __slim = function (v, depth) {
11694
+ // Typed arrays too: collision positions are a Float32Array, which
11695
+ // Array.isArray misses, so a star still came back as 12 KB (gate run 2).
11696
+ if (ArrayBuffer.isView(v)) return v.length > 16 ? { length: v.length } : Array.prototype.slice.call(v);
11697
+ if (Array.isArray(v)) return v.length > 16 ? { length: v.length } : v.map(function (x) { return __slim(x, depth + 1); });
11698
+ 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; }
11699
+ return v;
11700
+ };
11701
+ const __m = app.listWorldMeshes() || [];
11702
+ return { success: true, count: __m.length, meshes: __m.map(function (m) { return __slim(m, 0); }) };
11012
11703
  })();`.trim();
11013
11704
  case 'remove_mesh':
11014
11705
  return `
@@ -11178,7 +11869,10 @@ ${needWorld}
11178
11869
  (function() {
11179
11870
  if (typeof app.worldToCanvas !== 'function') { return { success: false, error: 'app.worldToCanvas unavailable — update FxTool' }; }
11180
11871
  ${needWorld}
11181
- return { success: true, point: app.worldToCanvas(${S(input.point)}) };
11872
+ // projectToCanvas destructures [x, y, z]; an {x, y, z} object threw "is not iterable" (gate D5).
11873
+ const __p = ${S(input.point)};
11874
+ const __pt = app.worldToCanvas(Array.isArray(__p) ? __p : [__p.x, __p.y, __p.z ?? 0]);
11875
+ 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
11876
  })();`.trim();
11183
11877
  case 'canvas_to_ground':
11184
11878
  return `
@@ -11285,14 +11979,20 @@ ${needWorld}
11285
11979
  * already decided by a pure function, so nothing is left for the page to
11286
11980
  * work out.
11287
11981
  */
11288
- generateDesignCompose(scene, ops, style) {
11982
+ generateDesignCompose(scene, ops, style, sizeWarning) {
11289
11983
  const S = (v) => JSON.stringify(v);
11290
11984
  return `
11291
11985
  // Design: compose a ${style} scene — ${ops.length} items, laid out server-side
11292
11986
  (function() {
11293
11987
  if (typeof app.create !== 'function') { return { success: false, error: 'app.create unavailable — update FxTool' }; }
11294
11988
  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 */ } }
11989
+ // The layout was computed for this size, so the canvas is set to it — and
11990
+ // the result SAYS so: a 1080x1080 canvas became 1280x720 silently (gate C).
11991
+ const __before = app.getCanvasSize ? app.getCanvasSize() : null;
11992
+ if (typeof app.setCanvasSize === 'function') { try { app.setCanvasSize({ width: size.width, height: size.height }); } catch (_) { /* a fixed canvas is not a failure */ } }
11993
+ const __after = app.getCanvasSize ? app.getCanvasSize() : null;
11994
+ const canvasResized = __before && __after && (__before.width !== __after.width || __before.height !== __after.height)
11995
+ ? { from: { width: __before.width, height: __before.height }, to: { width: __after.width, height: __after.height } } : null;
11296
11996
  ${scene.backgroundHex ? `if (typeof app.setBackgroundColor === 'function') { try { app.setBackgroundColor(${S(scene.backgroundHex)}); } catch (_) { /* ditto */ } }` : '// this style paints no background of its own'}
11297
11997
  const ops = ${S(ops)};
11298
11998
  const ids = [];
@@ -11307,7 +12007,9 @@ ${needWorld}
11307
12007
  if (app.historyManager) app.historyManager.saveState();
11308
12008
  // A partial composition is reported as one. Half a poster that claims
11309
12009
  // 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 };
12010
+ return { success: failed.length === 0, style: ${S(style)}, itemIds: ids, itemCount: ids.length, failed: failed,
12011
+ ...(canvasResized ? { canvasResized: canvasResized, note: 'the canvas was resized to the composition' + (${S(sizeWarning ?? '')} ? '' : '; pass width and height to compose at your canvas size') } : {}),
12012
+ ...(${S(sizeWarning ?? '')} ? { warning: ${S(sizeWarning ?? '')} } : {}) };
11311
12013
  })();`.trim();
11312
12014
  }
11313
12015
  /**
@@ -11448,9 +12150,13 @@ ${needWorld}
11448
12150
  // Interchange: ${fn}
11449
12151
  (async function() {
11450
12152
  ${guard(fn)}
12153
+ ${TO_DATA_URL_JS}
11451
12154
  const out = await app.${fn}(${opts});
11452
12155
  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 };
12156
+ ${input.action === 'export_lottie'
12157
+ ? `return { success: true, format: 'lottie', data: out };`
12158
+ : `const __url = await __ppToDataUrl(out, 'application/zip');
12159
+ return __url ? { success: true, format: 'dotlottie', data: __url } : { success: false, error: 'exportDotLottie returned something that is not binary' };`}
11454
12160
  })();`.trim();
11455
12161
  }
11456
12162
  case 'import_lottie':
@@ -11460,9 +12166,23 @@ ${guard(fn)}
11460
12166
  ${guard('importLottie')}
11461
12167
  // importLottie logs and returns falsy when the importer is absent — a
11462
12168
  // stripped console makes that indistinguishable from an empty animation.
12169
+ ${REG_ID_JS}
11463
12170
  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 };
12171
+ // importLottie ALWAYS answers { success, items, duration, warnings, errors };
12172
+ // only !r was checked, so a failed import read as a success, and the ids
12173
+ // were looked for on fields it does not have (gate N2: itemId null).
12174
+ if (!r || r.success === false) {
12175
+ const why = r && Array.isArray(r.errors) && r.errors.length ? r.errors.join('; ') : 'the Lottie did not import — it may be malformed';
12176
+ return { success: false, error: why, ...(r && r.warnings && r.warnings.length ? { warnings: r.warnings } : {}) };
12177
+ }
12178
+ const __items = Array.isArray(r.items) ? r.items : [];
12179
+ const itemIds = __items.map(function (it) { return __ppRegId(it); }).filter(Boolean);
12180
+ if (!__items.length) return { success: false, error: 'the Lottie imported no items' };
12181
+ let __b = null;
12182
+ __items.forEach(function (it) { if (it && it.bounds) __b = __b ? __b.unite(it.bounds) : it.bounds; });
12183
+ return { success: true, itemId: itemIds[0] || null, itemIds: itemIds, items: __items.length, duration: r.duration,
12184
+ bounds: __b ? { x: __b.x, y: __b.y, width: __b.width, height: __b.height } : undefined,
12185
+ ...(r.warnings && r.warnings.length ? { warnings: r.warnings } : {}) };
11466
12186
  })();`.trim();
11467
12187
  case 'export_glb':
11468
12188
  return `
@@ -11473,9 +12193,11 @@ ${guard('exportGLB')}
11473
12193
  // stripped in production, so the precondition is checked here instead.
11474
12194
  const has3d = app._initialized && app._initialized.has && app._initialized.has('threeD');
11475
12195
  if (!has3d) { return { success: false, error: 'no perspective objects to export — create one with createObject3D first' }; }
12196
+ ${TO_DATA_URL_JS}
11476
12197
  const out = await app.exportGLB(${opts});
11477
12198
  if (!out) { return { success: false, error: 'GLB export produced nothing' }; }
11478
- return { success: true, format: 'glb', data: out };
12199
+ const __url = await __ppToDataUrl(out, 'model/gltf-binary');
12200
+ return __url ? { success: true, format: 'glb', data: __url } : { success: true, format: 'glb', data: out };
11479
12201
  })();`.trim();
11480
12202
  case 'export_bvh':
11481
12203
  return `
@@ -11536,7 +12258,16 @@ ${guard('exportPNGSequence')}
11536
12258
  generateSound(input) {
11537
12259
  const S = (v) => JSON.stringify(v);
11538
12260
  const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
11539
- const simple = (fn, args, key, label) => `
12261
+ const simple = (fn, args, key, label) => key === 'played' ? `
12262
+ // Sound: ${label}
12263
+ (function() {
12264
+ ${guard(fn)}
12265
+ // A play that returns no voice played nothing, and reported success (gate
12266
+ // sweep D, 1.6.19). A headless studio may have no audio output at all.
12267
+ const __v = app.${fn}(${args});
12268
+ 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.' }; }
12269
+ return { success: true, played: __v };
12270
+ })();`.trim() : `
11540
12271
  // Sound: ${label}
11541
12272
  (function() {
11542
12273
  ${guard(fn)}
@@ -11551,8 +12282,12 @@ ${guard(fn)}
11551
12282
  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
12283
  case 'play_percussion': return simple('playPercussion', `${S(input.name)}, ${S(input.options ?? {})}`, 'played', `percussion ${input.name}`);
11553
12284
  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}"`);
12285
+ // options are merged into the spec, as play_tone's are; play_spec and
12286
+ // play_from_text dropped them (gate sweep D).
12287
+ case 'play_spec': return simple('playSound', `Object.assign({}, ${S(input.spec)}, ${S(input.options ?? {})})`, 'played', 'a raw spec');
12288
+ case 'play_from_text': return input.options
12289
+ ? simple('playSound', `Object.assign({}, app.buildSoundFromText(${S(input.text)}) || {}, ${S(input.options)})`, 'played', 'a described sound')
12290
+ : simple('playSoundFromText', S(input.text), 'played', 'a described sound');
11556
12291
  case 'from_text':
11557
12292
  return `
11558
12293
  // Sound: resolve "${input.text}" to a spec WITHOUT playing it
@@ -11712,7 +12447,9 @@ ${guard('createSound')}
11712
12447
  for (let i = 0; i < cues.length; i++) {
11713
12448
  const c = cues[i];
11714
12449
  try {
11715
- const spec = c.spec || (c.preset ? { preset: c.preset, note: c.note } : null);
12450
+ // The engine reads spec.instrument and never preset, so a preset cue
12451
+ // played a plain sine (gate sweep D).
12452
+ const spec = c.spec || (c.preset ? { instrument: c.preset, note: c.note } : null);
11716
12453
  if (!spec) { failed.push({ index: i, t: c.t, error: 'neither spec nor preset' }); continue; }
11717
12454
  const item = app.createSound(spec, c.visual || {});
11718
12455
  if (!item) { failed.push({ index: i, t: c.t, error: 'the cue produced no waveform path' }); continue; }
@@ -11801,10 +12538,20 @@ ${guard('timbreFromPath')}
11801
12538
  generatePathOp(input) {
11802
12539
  const S = (v) => JSON.stringify(v);
11803
12540
  const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
12541
+ // Paper items in the engine's answer become their registry ids: spread
12542
+ // as-is they serialised to raw Paper dumps (1.6.19 gate: boolean,
12543
+ // outline_stroke and pattern).
11804
12544
  const pass = (expr) => `
12545
+ ${REG_ID_JS}
12546
+ const __isItem = function (v) { return v && typeof v === 'object' && typeof v.className === 'string' && 'bounds' in v; };
12547
+ const __clean = function (v) { return __isItem(v) ? (__ppRegId(v) || ('paper:' + v.id)) : (Array.isArray(v) && v.some(__isItem) ? v.map(__clean) : v); };
11805
12548
  const r = ${expr};
11806
12549
  if (!r || r.ok === false) { return { success: false, error: (r && r.reason) || 'the engine refused the operation' }; }
11807
- return { success: true, ...r };`;
12550
+ const out = { success: true };
12551
+ Object.keys(r).forEach(function (k) { out[k] = __clean(r[k]); });
12552
+ // itemId, as every other tool names it; the engine says id (gate run 3).
12553
+ if (out.itemId === undefined && typeof out.id === 'string') out.itemId = out.id;
12554
+ return out;`;
11808
12555
  switch (input.action) {
11809
12556
  case 'boolean':
11810
12557
  return `
@@ -12146,7 +12893,14 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
12146
12893
  return wrap('Rigging: bake animation to keyframes', ` const res = R.bakeAnimation(${S(input.skeletonId)}, ${S(input.options ?? {})});
12147
12894
  if (res && app.historyManager) app.historyManager.saveState();
12148
12895
  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 };`);
12896
+ // bakeAnimation returns a MAP (itemId -> keyframes), which serialised to {},
12897
+ // and its report is a non-enumerable bakeReport (gate run 3). Read both.
12898
+ const rep = res.bakeReport || {};
12899
+ const detached = rep.detached || 0;
12900
+ return { success: (rep.written || 0) > 0 || ${input.options && input.options.write === false ? 'true' : 'false'}, action: 'bake_animation',
12901
+ items: res.size, written: rep.written || 0, keyframes: rep.keyframes || 0, cleared: rep.cleared || 0, detached: detached,
12902
+ wrote: rep.wrote || [], ...(rep.skipped && rep.skipped.length ? { skipped: rep.skipped } : {}),
12903
+ ...(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
12904
  case 'list_shape_keys':
12151
12905
  return wrap('Rigging: list shape keys', ` const keys = R.listShapeKeys(${S(input.skeletonId)});
12152
12906
  return { success: true, action: 'list_shape_keys', shapeKeys: keys, count: keys.length };`);