@pinepaper.studio/mcp-server 1.6.6 → 1.6.8

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 (178) hide show
  1. package/README.de.md +24 -1
  2. package/README.es.md +24 -1
  3. package/README.fr.md +24 -1
  4. package/README.hi.md +21 -1
  5. package/README.ja.md +22 -1
  6. package/README.ko.md +22 -1
  7. package/README.md +329 -14
  8. package/README.pt-BR.md +24 -1
  9. package/README.zh-CN.md +20 -1
  10. package/assets/edit-whenever.svg +75 -0
  11. package/dist/agent/export-manager.d.ts.map +1 -1
  12. package/dist/agent/export-manager.js +2 -2
  13. package/dist/agent/export-manager.js.map +1 -1
  14. package/dist/agent/session-manager.d.ts.map +1 -1
  15. package/dist/agent/session-manager.js.map +1 -1
  16. package/dist/assets/repositories/fontawesome.d.ts.map +1 -1
  17. package/dist/assets/repositories/fontawesome.js.map +1 -1
  18. package/dist/assets/repositories/openclipart.d.ts.map +1 -1
  19. package/dist/character/data/concepts.json +1 -0
  20. package/dist/character/data/depictions.json +1 -0
  21. package/dist/character/data/styles.json +1 -0
  22. package/dist/character/duration-scale.d.ts +13 -0
  23. package/dist/character/duration-scale.d.ts.map +1 -0
  24. package/dist/character/duration-scale.js +13 -0
  25. package/dist/character/duration-scale.js.map +1 -0
  26. package/dist/character/path-bbox.d.ts +82 -0
  27. package/dist/character/path-bbox.d.ts.map +1 -0
  28. package/dist/character/path-bbox.js +496 -0
  29. package/dist/character/path-bbox.js.map +1 -0
  30. package/dist/character/perform.d.ts +149 -0
  31. package/dist/character/perform.d.ts.map +1 -0
  32. package/dist/character/perform.js +626 -0
  33. package/dist/character/perform.js.map +1 -0
  34. package/dist/character/resolve.d.ts +51 -0
  35. package/dist/character/resolve.d.ts.map +1 -0
  36. package/dist/character/resolve.js +239 -0
  37. package/dist/character/resolve.js.map +1 -0
  38. package/dist/design/design-systems.d.ts +126 -0
  39. package/dist/design/design-systems.d.ts.map +1 -0
  40. package/dist/design/design-systems.js +240 -0
  41. package/dist/design/design-systems.js.map +1 -0
  42. package/dist/execution/canvas-state.d.ts +7 -0
  43. package/dist/execution/canvas-state.d.ts.map +1 -1
  44. package/dist/execution/canvas-state.js +20 -8
  45. package/dist/execution/canvas-state.js.map +1 -1
  46. package/dist/index.d.ts +20 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -5
  49. package/dist/index.js.map +1 -1
  50. package/dist/ontology/design-graph.d.ts +9 -0
  51. package/dist/ontology/design-graph.d.ts.map +1 -1
  52. package/dist/ontology/design-graph.js +98 -4
  53. package/dist/ontology/design-graph.js.map +1 -1
  54. package/dist/ontology/types.d.ts +42 -0
  55. package/dist/ontology/types.d.ts.map +1 -1
  56. package/dist/ontology/vocabulary.d.ts.map +1 -1
  57. package/dist/ontology/vocabulary.js +139 -0
  58. package/dist/ontology/vocabulary.js.map +1 -1
  59. package/dist/taxonomy/classifier.js +1 -1
  60. package/dist/taxonomy/classifier.js.map +1 -1
  61. package/dist/taxonomy/error-recovery.d.ts.map +1 -1
  62. package/dist/taxonomy/matcher.d.ts.map +1 -1
  63. package/dist/taxonomy/matcher.js.map +1 -1
  64. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  65. package/dist/tools/compact-descriptions.js +25 -0
  66. package/dist/tools/compact-descriptions.js.map +1 -1
  67. package/dist/tools/definitions.d.ts +1 -1
  68. package/dist/tools/definitions.d.ts.map +1 -1
  69. package/dist/tools/definitions.js +1236 -45
  70. package/dist/tools/definitions.js.map +1 -1
  71. package/dist/tools/engine-methods.d.ts +18 -0
  72. package/dist/tools/engine-methods.d.ts.map +1 -0
  73. package/dist/tools/engine-methods.js +239 -0
  74. package/dist/tools/engine-methods.js.map +1 -0
  75. package/dist/tools/handlers/character.d.ts +121 -0
  76. package/dist/tools/handlers/character.d.ts.map +1 -0
  77. package/dist/tools/handlers/character.js +315 -0
  78. package/dist/tools/handlers/character.js.map +1 -0
  79. package/dist/tools/handlers.d.ts +2 -0
  80. package/dist/tools/handlers.d.ts.map +1 -1
  81. package/dist/tools/handlers.js +386 -22
  82. package/dist/tools/handlers.js.map +1 -1
  83. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  84. package/dist/tools/minimal-descriptions.js +24 -4
  85. package/dist/tools/minimal-descriptions.js.map +1 -1
  86. package/dist/tools/toolkits.d.ts.map +1 -1
  87. package/dist/tools/toolkits.js +21 -0
  88. package/dist/tools/toolkits.js.map +1 -1
  89. package/dist/types/code-generator.d.ts +141 -2
  90. package/dist/types/code-generator.d.ts.map +1 -1
  91. package/dist/types/code-generator.js +2246 -78
  92. package/dist/types/code-generator.js.map +1 -1
  93. package/dist/types/schemas.d.ts +9295 -603
  94. package/dist/types/schemas.d.ts.map +1 -1
  95. package/dist/types/schemas.js +1134 -61
  96. package/dist/types/schemas.js.map +1 -1
  97. package/dist/vendor/design/art-deco-generator.d.ts +52 -0
  98. package/dist/vendor/design/art-deco-generator.d.ts.map +1 -0
  99. package/dist/vendor/design/art-deco-generator.js +144 -0
  100. package/dist/vendor/design/art-deco-generator.js.map +1 -0
  101. package/dist/vendor/design/art-nouveau-generator.d.ts +46 -0
  102. package/dist/vendor/design/art-nouveau-generator.d.ts.map +1 -0
  103. package/dist/vendor/design/art-nouveau-generator.js +134 -0
  104. package/dist/vendor/design/art-nouveau-generator.js.map +1 -0
  105. package/dist/vendor/design/bauhaus-generator.d.ts +53 -0
  106. package/dist/vendor/design/bauhaus-generator.d.ts.map +1 -0
  107. package/dist/vendor/design/bauhaus-generator.js +126 -0
  108. package/dist/vendor/design/bauhaus-generator.js.map +1 -0
  109. package/dist/vendor/design/brutalist-generator.d.ts +53 -0
  110. package/dist/vendor/design/brutalist-generator.d.ts.map +1 -0
  111. package/dist/vendor/design/brutalist-generator.js +148 -0
  112. package/dist/vendor/design/brutalist-generator.js.map +1 -0
  113. package/dist/vendor/design/claymorphism-generator.d.ts +46 -0
  114. package/dist/vendor/design/claymorphism-generator.d.ts.map +1 -0
  115. package/dist/vendor/design/claymorphism-generator.js +135 -0
  116. package/dist/vendor/design/claymorphism-generator.js.map +1 -0
  117. package/dist/vendor/design/corporate-memphis-generator.d.ts +47 -0
  118. package/dist/vendor/design/corporate-memphis-generator.d.ts.map +1 -0
  119. package/dist/vendor/design/corporate-memphis-generator.js +144 -0
  120. package/dist/vendor/design/corporate-memphis-generator.js.map +1 -0
  121. package/dist/vendor/design/cyberpunk-generator.d.ts +52 -0
  122. package/dist/vendor/design/cyberpunk-generator.d.ts.map +1 -0
  123. package/dist/vendor/design/cyberpunk-generator.js +162 -0
  124. package/dist/vendor/design/cyberpunk-generator.js.map +1 -0
  125. package/dist/vendor/design/de-stijl-generator.d.ts +50 -0
  126. package/dist/vendor/design/de-stijl-generator.d.ts.map +1 -0
  127. package/dist/vendor/design/de-stijl-generator.js +154 -0
  128. package/dist/vendor/design/de-stijl-generator.js.map +1 -0
  129. package/dist/vendor/design/design-style.d.ts +3 -0
  130. package/dist/vendor/design/design-style.d.ts.map +1 -0
  131. package/dist/vendor/design/design-style.js +12 -0
  132. package/dist/vendor/design/design-style.js.map +1 -0
  133. package/dist/vendor/design/design-systems-dtcg.d.ts +59 -0
  134. package/dist/vendor/design/design-systems-dtcg.d.ts.map +1 -0
  135. package/dist/vendor/design/design-systems-dtcg.js +571 -0
  136. package/dist/vendor/design/design-systems-dtcg.js.map +1 -0
  137. package/dist/vendor/design/frutiger-aero-generator.d.ts +46 -0
  138. package/dist/vendor/design/frutiger-aero-generator.d.ts.map +1 -0
  139. package/dist/vendor/design/frutiger-aero-generator.js +133 -0
  140. package/dist/vendor/design/frutiger-aero-generator.js.map +1 -0
  141. package/dist/vendor/design/memphis-generator.d.ts +56 -0
  142. package/dist/vendor/design/memphis-generator.d.ts.map +1 -0
  143. package/dist/vendor/design/memphis-generator.js +158 -0
  144. package/dist/vendor/design/memphis-generator.js.map +1 -0
  145. package/dist/vendor/design/mid-century-upa-generator.d.ts +46 -0
  146. package/dist/vendor/design/mid-century-upa-generator.d.ts.map +1 -0
  147. package/dist/vendor/design/mid-century-upa-generator.js +137 -0
  148. package/dist/vendor/design/mid-century-upa-generator.js.map +1 -0
  149. package/dist/vendor/design/neumorphic-generator.d.ts +53 -0
  150. package/dist/vendor/design/neumorphic-generator.d.ts.map +1 -0
  151. package/dist/vendor/design/neumorphic-generator.js +182 -0
  152. package/dist/vendor/design/neumorphic-generator.js.map +1 -0
  153. package/dist/vendor/design/op-art-generator.d.ts +45 -0
  154. package/dist/vendor/design/op-art-generator.d.ts.map +1 -0
  155. package/dist/vendor/design/op-art-generator.js +116 -0
  156. package/dist/vendor/design/op-art-generator.js.map +1 -0
  157. package/dist/vendor/design/pixel-art-generator.d.ts +45 -0
  158. package/dist/vendor/design/pixel-art-generator.d.ts.map +1 -0
  159. package/dist/vendor/design/pixel-art-generator.js +143 -0
  160. package/dist/vendor/design/pixel-art-generator.js.map +1 -0
  161. package/dist/vendor/design/pop-art-generator.d.ts +53 -0
  162. package/dist/vendor/design/pop-art-generator.d.ts.map +1 -0
  163. package/dist/vendor/design/pop-art-generator.js +164 -0
  164. package/dist/vendor/design/pop-art-generator.js.map +1 -0
  165. package/dist/vendor/design/risograph-generator.d.ts +53 -0
  166. package/dist/vendor/design/risograph-generator.d.ts.map +1 -0
  167. package/dist/vendor/design/risograph-generator.js +170 -0
  168. package/dist/vendor/design/risograph-generator.js.map +1 -0
  169. package/dist/vendor/design/swiss-style-generator.d.ts +55 -0
  170. package/dist/vendor/design/swiss-style-generator.d.ts.map +1 -0
  171. package/dist/vendor/design/swiss-style-generator.js +147 -0
  172. package/dist/vendor/design/swiss-style-generator.js.map +1 -0
  173. package/dist/vendor/design/vaporwave-generator.d.ts +51 -0
  174. package/dist/vendor/design/vaporwave-generator.d.ts.map +1 -0
  175. package/dist/vendor/design/vaporwave-generator.js +139 -0
  176. package/dist/vendor/design/vaporwave-generator.js.map +1 -0
  177. package/manifest.json +65 -1
  178. package/package.json +18 -9
@@ -98,6 +98,44 @@ export const ItemTypeSchema = z.enum([
98
98
  'diamond',
99
99
  'arrow',
100
100
  'heart',
101
+ // THE REST OF THE ENGINE'S SHAPE REGISTRY — same argument as the two below,
102
+ // applied to shapes that were already being drawn.
103
+ //
104
+ // js/shapes/index.js registers 34 shapes across five modules. Flowchart, UML
105
+ // and network route through pinepaper_create_diagram_shape, so 23 were
106
+ // reachable. These eleven were reachable from NOTHING: three basic shapes,
107
+ // and the whole `comment` module — every speech bubble, thought bubble and
108
+ // callout the engine can draw.
109
+ //
110
+ // The engine was never the limit. PinePaper.create() delegates any registry
111
+ // type — "basic shapes, comment/bubble shapes, etc." in its own comment —
112
+ // and already forwards `tailDirection` and `tailSize`, which exist only for
113
+ // bubbles. It has been able to draw a speech bubble the whole time; no tool
114
+ // could name one, so an agent asked for a comic panel or an annotated
115
+ // diagram had to hand-build the outline or decline.
116
+ 'disk',
117
+ 'circle-outline',
118
+ 'arrow-right',
119
+ 'speech-bubble',
120
+ 'speech-bubble-square',
121
+ 'speech-bubble-pointed',
122
+ 'thought-bubble',
123
+ 'double-bubble',
124
+ 'quote-bubble',
125
+ 'comment-box',
126
+ 'callout-box',
127
+ // RENDER-TIME SURFACES. These two are not Paper items and never enter the
128
+ // scene tree: they are drawn per pixel by the cloud rasterizer, straight from
129
+ // the scene document. They are here because a capability nobody can name is a
130
+ // capability nobody has — the cloud has drawn shaders and fields for months
131
+ // and no tool has ever offered them, so the only way to reach one was to
132
+ // hand-write a scene file.
133
+ //
134
+ // In the browser they degrade to a flat plate of their own colour (see
135
+ // generateCreateItemCode) so a composition built with one still reads
136
+ // locally; the shaded surface appears in a cloud render.
137
+ 'shader',
138
+ 'field',
101
139
  ]);
102
140
  // Text item properties
103
141
  export const TextPropertiesSchema = z.object({
@@ -182,14 +220,32 @@ export const ArcPropertiesSchema = z.object({
182
220
  // =============================================================================
183
221
  // ANIMATION TYPES
184
222
  // =============================================================================
223
+ // The loop animations the engine's driver can actually run.
224
+ //
225
+ // Pinned to the engine by animation-type-parity.test.ts. It offered 'slide',
226
+ // which has NEVER existed — the real ones are slideLeftRight and slideUpDown.
227
+ // The engine stored the unknown value and the driver's switch fell through, so
228
+ // the item sat still while its data claimed a slide, and nothing anywhere said
229
+ // so. It also hid twelve types that do work.
185
230
  export const SimpleAnimationTypeSchema = z.enum([
186
- 'pulse',
187
- 'rotate',
188
231
  'bounce',
232
+ 'breathe',
189
233
  'fade',
190
- 'wobble',
191
- 'slide',
234
+ 'glow',
235
+ 'jelly',
236
+ 'path',
237
+ 'pulse',
238
+ 'rotate',
239
+ 'scrollDown',
240
+ 'scrollLeft',
241
+ 'scrollRight',
242
+ 'scrollUp',
243
+ 'shake',
244
+ 'slideLeftRight',
245
+ 'slideUpDown',
246
+ 'swing',
192
247
  'typewriter',
248
+ 'wobble',
193
249
  ]).describe('Simple loop animation type');
194
250
  // Keyframe schema
195
251
  export const KeyframeSchema = z.object({
@@ -200,7 +256,21 @@ export const KeyframeSchema = z.object({
200
256
  // =============================================================================
201
257
  // RELATION TYPES
202
258
  // =============================================================================
259
+ // The relation vocabulary the engine can actually run.
260
+ //
261
+ // Held in step with the engine by relation-parity.test.ts, against a fixture of
262
+ // the registry's own map. This list is a hard gate: a name missing from it is
263
+ // REJECTED at validation, so for 95 live relations the engine could do it and
264
+ // no agent could name it — and for a model those are the same condition. The
265
+ // whole interactive vocabulary sat on the wrong side of that line.
266
+ //
267
+ // Relations a dedicated tool emits (deform_*, effect_*, geo_*, bone_*, …) are
268
+ // deliberately absent: the agent authors those THROUGH that tool, and a second
269
+ // name here would be a worse way to do the same thing. The test file holds that
270
+ // exclusion list, with the reason.
271
+ /** Engine parity ENFORCED BY src/__tests__/unit/relation-parity.test.ts (fixture: engine-relations.txt). */
203
272
  export const RelationTypeSchema = z.enum([
273
+ // --- Motion, spatial and transform ---
204
274
  'orbits',
205
275
  'follows',
206
276
  'attached_to',
@@ -209,61 +279,100 @@ export const RelationTypeSchema = z.enum([
209
279
  'mirrors',
210
280
  'parallax',
211
281
  'bounds_to',
282
+ 'anchored_in_world',
283
+ 'spring_follow',
284
+ 'repels',
285
+ 'attracts',
286
+ 'wiggle',
287
+ // --- Animation and sequencing ---
212
288
  'animates',
213
- // Manim-inspired animation relations
214
289
  'grows_from',
215
290
  'staggered_with',
216
291
  'indicates',
217
292
  'circumscribes',
218
293
  'wave_through',
219
- 'camera_follows',
220
- 'camera_animates',
221
294
  'morphs_to',
222
- // Pair-by-index group morph; same call shape works for any two paper.Groups
223
295
  'group_morphs_to',
224
- // Self-relation; item moves along a custom-drawn path stored in params
225
296
  'moves_along_path',
226
- // Geometric construction constraints (Layer 2) — the source is RE-DERIVED each
227
- // frame from its anchor item(s), so dragging an anchor updates it live
228
- // (GeoGebra-style). Anchor A is the relation target; extra anchors ride in params.
229
- 'is_midpoint_of', // source = midpoint(target, params.other)
230
- 'lies_on_line', // source on line target→params.other at fraction params.t
231
- 'is_centroid_of', // source = centroid(target, ...params.others)
232
- 'is_circumcenter_of', // source = circumcenter(target, params.other1, params.other2)
233
- 'concentric_with', // source shares the target's center
234
- 'construction_reveal', // self-relation: opacity 0→1 at params.revealAt over params.fadeIn (timeline-driven)
235
- // Procedural / deterministic property binding (Expression IR — FxTool S10 G1).
236
- // params.signal:true routes through the pure, seed/frame-deterministic signal
237
- // interpreter (replay-stable), else the per-frame compute fallback runs.
238
- 'driven_by', // source property = target property * multiplier + offset (+clamp); drives color/stroke/opacity/scale/pos. Needs a target.
239
- 'time_expression', // self-relation: source property driven by a math expression f(t, v) — t=time, v=params.baseValue
240
- // Event-driven scene chains (FxTool S11). The source is a pp:event (make one
241
- // with pinepaper_event); firing it runs these reactions. on_event_fire_after
242
- // chains events over canvas/wall time; on_event_add_relation mutates the graph
243
- // (the scene evolves itself). Kick a chain off with pinepaper_event pulse.
244
- 'on_event_fire_after', // event→event: when source fires, pulse target after params.delay (timeline: wall|canvas)
245
- 'on_event_add_relation', // event→item: add params.type relation from item to params.target when the event fires
246
- 'on_event_remove_relation', // event→item: remove params.type relation from item to params.target
247
- 'on_event_set_color', // event→item: set fill/stroke to params.color
248
- 'on_event_set_property', // event→item: set item[params.property] = params.value
249
- 'on_event_set_visibility', // event→item: show/hide (params.visible, default true)
250
- // Structural layout relations (FxTool S12-E1) — STATIC composition as graph edges.
251
- // Placement is derived from the TARGET'S BOUNDS and re-derived each frame, so
252
- // dragging or resizing the target moves the dependent live. Use these instead of
253
- // hardcoding x/y when the intent is "on", "under", "next to", "inside".
254
- 'on_top_of', // source bottom edge rests on target top edge (params: gap, align, overhang)
255
- 'below', // source top edge rests on target bottom edge (params: gap, align, overhang)
256
- 'beside', // source flanks the target left/right (params: side, gap, align)
257
- 'inside', // source anchored inside the target's bounds (params: anchor, padding)
258
- 'centered_on', // source center = target center + (offsetX, offsetY)
259
- 'aligned_with', // source matches target on ONE axis only — the other stays free (params: axis, offset)
297
+ 'construction_reveal',
298
+ 'triggers_animation',
299
+ 'syncs_with',
300
+ 'tours',
301
+ 'synced_to_audio',
302
+ 'expresses',
303
+ // --- Camera ---
304
+ 'camera_follows',
305
+ 'camera_animates',
306
+ // --- Geometry ---
307
+ 'is_midpoint_of',
308
+ 'lies_on_line',
309
+ 'is_centroid_of',
310
+ 'is_circumcenter_of',
311
+ 'concentric_with',
312
+ // --- Procedural / expression ---
313
+ 'driven_by',
314
+ 'time_expression',
315
+ // --- Input triggers — a user action FIRES an event channel ---
316
+ 'on_click_fire',
317
+ 'on_pointer_enter_fire',
318
+ 'on_pointer_exit_fire',
319
+ 'on_key_fire',
320
+ // --- Event reactions — an event fires and something happens ---
321
+ 'on_event_fire_after',
322
+ 'on_event_fire_if',
323
+ 'on_event_add_relation',
324
+ 'on_event_remove_relation',
325
+ 'on_event_set_color',
326
+ 'on_event_set_property',
327
+ 'on_event_set_property_from_template',
328
+ 'on_event_set_visibility',
329
+ 'on_event_set_active',
330
+ 'on_event_set_data',
331
+ 'on_event_increment',
332
+ 'on_event_toggle',
333
+ 'on_event_store_set',
334
+ 'on_event_store_increment',
335
+ // --- Proximity — the pointer entering or leaving an item ---
336
+ 'on_enter_set_color',
337
+ 'on_enter_set_property',
338
+ 'on_enter_set_property_from_template',
339
+ 'on_enter_set_visibility',
340
+ 'on_enter_set_data',
341
+ 'on_enter_increment',
342
+ 'on_enter_toggle',
343
+ 'on_exit_set_color',
344
+ 'on_exit_set_property',
345
+ 'on_exit_set_property_from_template',
346
+ 'on_exit_set_visibility',
347
+ 'on_exit_set_data',
348
+ 'on_exit_increment',
349
+ 'on_exit_toggle',
350
+ // --- Exclusive state — at most one member active ---
351
+ 'exclusive_group',
352
+ 'menubar_group',
353
+ 'restores_from',
354
+ // --- Structural and diagram ---
355
+ 'on_top_of',
356
+ 'below',
357
+ 'beside',
358
+ 'inside',
359
+ 'centered_on',
360
+ 'aligned_with',
361
+ 'part_of',
362
+ 'connects_to',
363
+ 'attached_to_tail',
364
+ 'head_points_to',
260
365
  ]).describe('Type of relationship between items');
261
366
  // Relation parameter schemas
262
367
  export const OrbitsParamsSchema = z.object({
263
368
  radius: z.number().optional().default(100).describe('Orbit radius'),
264
369
  speed: z.number().optional().default(1).describe('Rotation speed'),
265
370
  direction: z.enum(['clockwise', 'counterclockwise']).optional().default('counterclockwise'),
266
- phase: z.number().optional().default(0).describe('Starting angle offset'),
371
+ // The one angle in the relation vocabulary that is NOT degrees. Kept because
372
+ // changing it outright would silently re-time every scene that sets it — a 57x
373
+ // error of exactly the kind the "angles are DEGREES" convention exists to stop.
374
+ phase: z.number().optional().default(0).describe('Starting angle in RADIANS — the documented exception to the degrees convention, kept for existing scenes. Prefer phaseDegrees.'),
375
+ phaseDegrees: z.number().optional().describe('Starting angle in DEGREES, matching the convention every other angle uses. Takes precedence over phase when set.'),
267
376
  });
268
377
  export const FollowsParamsSchema = z.object({
269
378
  offset: PointArraySchema.optional().default([0, 0]).describe('Position offset from target'),
@@ -308,8 +417,19 @@ export const GrowsFromParamsSchema = z.object({
308
417
  });
309
418
  export const StaggeredWithParamsSchema = z.object({
310
419
  index: z.number().describe('0-based position in the stagger sequence'),
311
- stagger: z.number().optional().default(0.1).describe('Delay between items in seconds'),
312
- effect: z.enum(['fadeIn', 'fadeOut', 'growIn', 'slideIn', 'popIn']).optional().default('fadeIn').describe('Stagger effect type'),
420
+ stagger: z.number().optional().default(0.1).describe('Seconds between neighbours. Ignored when amount is set.'),
421
+ // Shape params (pp:Stagger). The plain index x stagger path is taken first and
422
+ // unchanged; these only engage when a shape is actually asked for AND count is
423
+ // known — there is no "end" to measure from without it, so an origin like
424
+ // 'center' would have nothing to mean.
425
+ count: z.number().optional().describe('How many items are in the stagger. Required by any origin other than "start".'),
426
+ from: z.enum(['start', 'end', 'center', 'edges', 'random']).optional().default('start').describe('Where the stagger begins — what turns a delay into a direction. Requires count.'),
427
+ amount: z.number().optional().describe('Total seconds for the WHOLE sequence, split among the items. Overrides stagger.'),
428
+ grid: z.string().optional().describe('Rows and columns as "rows,cols" (or "auto") for a 2D stagger. Requires count.'),
429
+ axis: z.enum(['x', 'y']).optional().describe('Restrict a grid stagger to one axis.'),
430
+ distributeEase: z.enum(['linear', 'easeIn', 'easeOut', 'easeInOut', 'easeInCubic', 'easeOutCubic']).optional().default('linear')
431
+ .describe("Curve that distributes the START TIMES — not the items' own animation curve."),
432
+ effect: z.enum(['fadeIn', 'fadeOut', 'growIn', 'slideIn', 'popIn', 'typewriter']).optional().default('fadeIn').describe('Stagger effect type'),
313
433
  });
314
434
  export const IndicatesParamsSchema = z.object({
315
435
  scale: z.number().optional().default(1.3).describe('Max scale during indication'),
@@ -448,6 +568,13 @@ export const MovesAlongPathParamsSchema = z.object({
448
568
  // || opts) — so they must survive validation.
449
569
  export const EquationPathInputSchema = z.object({
450
570
  ...equationSpecShape,
571
+ solveOde: z.object({
572
+ equations: z.union([z.string(), z.array(z.string())]).describe("The derivative(s), as expressions — e.g. 'y' for dy/dt = y, or ['v', '-9.8'] for a falling body."),
573
+ initialState: z.array(z.number()).describe('The state at t = 0, one number per equation.'),
574
+ tEnd: z.number().optional().describe('How far to integrate (default 10).'),
575
+ dt: z.number().optional().describe('Step size (default 0.01). Smaller is more accurate and slower.'),
576
+ method: z.enum(['rk4', 'euler']).optional().describe("Integrator (default 'rk4' — fourth-order Runge-Kutta). 'euler' is faster and visibly wrong on anything stiff."),
577
+ }).optional().describe('Numerically integrate an ODE instead of plotting a closed-form expression, and return the SOLUTION rather than drawing it. The trajectory comes back as data, so a caller can inspect it, feed it to a path, or drive keyframes with it.'),
451
578
  x: z.number().optional().describe('Canvas origin x (default: canvas center)'),
452
579
  y: z.number().optional().describe('Canvas origin y (default: canvas center)'),
453
580
  style: z.record(z.unknown()).optional().describe('Path style forwarded to create (strokeColor, strokeWidth, fillColor, …)'),
@@ -471,12 +598,22 @@ export const DrivenByParamsSchema = z.object({
471
598
  // time_expression: self-relation (targetId=null). The source's `property` is
472
599
  // driven by a math expression of t (scene time) and v (baseValue). signal:true
473
600
  // parses the expression into the Expression IR and evaluates a pure f(t)
474
- // (replay-stable), falling back to per-frame if it uses random()/unknown symbols.
601
+ // (replay-stable).
602
+ //
603
+ // THERE IS NO LONGER A PER-FRAME FALLBACK FOR random(). The engine used to
604
+ // build a `new Function` from the scene's own text on the per-frame path, which
605
+ // made the authoring language JavaScript rather than the grammar the other
606
+ // renderers speak — `^` was bitwise XOR there and exponentiation everywhere
607
+ // else, so one scene string drew two different pictures, and `random()` could
608
+ // not be refused, so a scene could never be baked or exported deterministically.
609
+ // Both paths now go through the same parser, `random` is rejected by name, and
610
+ // `^` is exponentiation. This comment said "falls back to per-frame" until
611
+ // 2026-09-15; it described behaviour the engine had removed.
475
612
  export const TimeExpressionParamsSchema = z.object({
476
613
  property: z.string().optional().default('y').describe('Property to drive: x, y, rotation, opacity, scale'),
477
- expression: z.string().optional().default('sin(t * 2) * 50 + 300').describe('Math expression of t (time) and v (baseValue), e.g. "sin(t*2)*50 + v"'),
614
+ expression: z.string().optional().default('sin(t * 2) * 50 + 300').describe('Math expression of t (time) and v (baseValue), e.g. "sin(t*2)*50 + v". `^` is exponentiation, not XOR. Available: sin cos tan asin acos atan sinh cosh tanh abs floor ceil round trunc sign sqrt cbrt exp log log2 log10 · pow min max atan2 hypot clamp mod · fract pingpong inverseLerp remap noise sec csc cot · easeInQuad easeOutQuad easeInOutQuad easeInCubic easeOutCubic easeInOutCubic · constants PI, E, TAU. `random()` is REJECTED by name on both the signal and per-frame paths — a scene that cannot render the same way twice can never be baked or exported deterministically.'),
478
615
  baseValue: z.number().optional().default(0).describe('Base value accessible as v in the expression'),
479
- signal: z.boolean().optional().default(false).describe('Deterministic IR mode — parse expression to IR, evaluate as pure f(t)'),
616
+ signal: z.boolean().optional().default(false).describe('Deterministic IR mode — parse expression to IR, evaluate as pure f(t). Both modes now use the same parser and the same grammar; signal additionally makes the result bakeable as a pure function of time.'),
480
617
  });
481
618
  // =============================================================================
482
619
  // EVENT-DRIVEN SCENE CHAINS (FxTool S11). Source of each is a pp:event.
@@ -609,6 +746,7 @@ export const ExecuteCustomCodeInputSchema = z.object({
609
746
  // =============================================================================
610
747
  // GENERATOR TYPES
611
748
  // =============================================================================
749
+ /** Engine parity ENFORCED BY src/__tests__/unit/preflight-gate-parity.test.ts (fixture: engine-generators.txt). */
612
750
  export const GeneratorNameSchema = z.enum([
613
751
  // Classic generators
614
752
  'drawSunburst',
@@ -683,6 +821,24 @@ export const GeneratorNameSchema = z.enum([
683
821
  'drawScatter',
684
822
  'drawStackedWaves',
685
823
  'draw3DParametricCurve',
824
+ // THE GATE WAS NARROWER THAN THE ENGINE, and generatorName is a hard
825
+ // preflight gate — an unlisted name is rejected before the browser sees
826
+ // it. The engine registers 74 generators and this offered 64, so these
827
+ // ten were live and uncallable. Seven are the GPU field generators, which
828
+ // is the expensive half: create_item's `field` type, design_medium's flow
829
+ // fields and the design graph's own recipe example all name drawGPUTunnel,
830
+ // so a captured scene recording `generator: 'drawGPUTunnel'` could not be
831
+ // replayed through the tool that exists to replay it.
832
+ 'draw3DWorld',
833
+ 'drawCharacterEyes',
834
+ 'drawGPUCaustics',
835
+ 'drawGPUClouds',
836
+ 'drawGPUOcean',
837
+ 'drawGPUPlasma',
838
+ 'drawGPUStarfield',
839
+ 'drawGPUTunnel',
840
+ 'drawGPUVoronoi',
841
+ 'drawRiggedCharacter',
686
842
  ]).describe('Background generator name');
687
843
  // ---------------------------------------------------------------------------
688
844
  // Generator parameter schemas — Phase 1 (enhanced existing)
@@ -835,6 +991,7 @@ export const NoiseTextureParamsSchema = z.object({
835
991
  // =============================================================================
836
992
  // EFFECT TYPES
837
993
  // =============================================================================
994
+ /** Engine parity ENFORCED BY src/__tests__/unit/preflight-gate-parity.test.ts (fixture: engine-effects.txt). */
838
995
  export const EffectTypeSchema = z.enum([
839
996
  'sparkle',
840
997
  'blast',
@@ -856,6 +1013,17 @@ export const EffectTypeSchema = z.enum([
856
1013
  'heatmap',
857
1014
  'liquid_metal',
858
1015
  'gem_smoke',
1016
+ 'electric_arc',
1017
+ 'vortex',
1018
+ 'rain_veil',
1019
+ 'caustics',
1020
+ // ink_bleed was the LAST aura and the gate rejected it. The engine's own
1021
+ // comment says it 'has shipped unroutable through applyEffect since it was
1022
+ // added' — past tense, describing a bug it then fixed: _publishNames()
1023
+ // publishes every aura definition, and PinePaper.applyEffect warms the
1024
+ // subsystem before routing, so all eight route now. Reading the comment
1025
+ // would have kept it out; reading the mechanism put it in.
1026
+ 'ink_bleed',
859
1027
  ]).describe('Visual effect type (particle effects + shader auras)');
860
1028
  export const SparkleParamsSchema = z.object({
861
1029
  color: ColorSchema.optional().default('#fbbf24').describe('Sparkle color'),
@@ -923,7 +1091,7 @@ export const CreateItemInputSchema = z.object({
923
1091
  itemType: ItemTypeSchema,
924
1092
  position: PositionSchema.optional().default({ x: 400, y: 300 }),
925
1093
  properties: z.record(z.unknown()).optional().default({}),
926
- animationType: z.string().optional().describe('Inline animation to apply on creation. Loop presets (pulse, rotate, bounce, fade, wobble, slide, typewriter) or "keyframe" with keyframes array. Equivalent to a follow-up pinepaper_animate / pinepaper_keyframe_animate call.'),
1094
+ animationType: z.string().optional().describe('Inline animation to apply on creation. Loop presets (bounce, breathe, fade, glow, jelly, path, pulse, rotate, scrollDown, scrollLeft, scrollRight, scrollUp, shake, slideLeftRight, slideUpDown, swing, typewriter, wobble) or "keyframe" with keyframes array. Equivalent to a follow-up pinepaper_animate / pinepaper_keyframe_animate call.'),
927
1095
  animationSpeed: z.number().optional().describe('Speed multiplier for loop animations (default: 1.0). Ignored when animationType is "keyframe".'),
928
1096
  animationIntensity: z.number().optional().describe('Loop animation amplitude (0.1 = ±10%, default 0.15). Drives pulse/wobble/bounce/breathe amplitude; honored by SVG/SMIL/widget export.'),
929
1097
  animationDelay: z.number().optional().describe('Loop animation start delay in seconds.'),
@@ -973,8 +1141,26 @@ export const DeleteItemInputSchema = z.object({
973
1141
  export const AddRelationInputSchema = z.object({
974
1142
  sourceId: z.string().describe('Registry ID of the source item'),
975
1143
  targetId: z.string().nullish().describe('Registry ID of the target item; null/omitted for self-relations (animates, moves_along_path, construction_reveal, time_expression)'),
976
- relationType: RelationTypeSchema,
1144
+ relationType: RelationTypeSchema.optional().describe('The relation to create. Required unless presetId is given.'),
977
1145
  params: z.record(z.unknown()).optional().default({}),
1146
+ presetId: z.string().optional()
1147
+ .describe("Apply a community relation PRESET instead of naming a type and params yourself — a tuned motion someone already got right, with its symbols exposed as knobs. List them with pinepaper_query_capabilities { action: 'catalogue', catalogue: 'relation_presets' }. The preset decides the relationType, so relationType is not needed and is ignored if sent."),
1148
+ presetValues: z.record(z.number()).optional()
1149
+ .describe('presetId: symbol → number. Any symbol you omit takes the midpoint of its declared range, so a preset applies sensibly with no values at all.'),
1150
+ })
1151
+ .refine((v) => !!v.relationType || !!v.presetId, { message: 'relationType is required unless presetId is given', path: ['relationType'] })
1152
+ // A PRESET IS A SELF-RELATION and has no target slot. applyRelationPreset
1153
+ // takes (item, presetId, values) — pass a targetId alongside one and the
1154
+ // emitted call has nowhere to put it, so a caller adopting a preset for a
1155
+ // two-item relation silently got a self-relation with no diagnostic.
1156
+ // Refused here rather than dropped there.
1157
+ .refine((v) => !v.presetId || v.targetId == null, {
1158
+ message: 'presetId cannot be combined with targetId — a relation preset applies to one item. Name the relationType directly for a two-item relation.',
1159
+ path: ['targetId'],
1160
+ })
1161
+ .refine((v) => !v.presetId || !v.relationType, {
1162
+ message: 'presetId cannot be combined with relationType — the preset decides its own type. Send one or the other.',
1163
+ path: ['relationType'],
978
1164
  });
979
1165
  // Remove Relation
980
1166
  export const RemoveRelationInputSchema = z.object({
@@ -1007,6 +1193,63 @@ export const KeyframeAnimateInputSchema = z.object({
1007
1193
  clipInPoint: z.number().optional().describe('Skip the first N seconds of keyframe data — clip head trim.'),
1008
1194
  clipOutPoint: z.number().optional().describe('Stop at N seconds into the keyframe data — clip tail trim. Default: lastKeyframeTime.'),
1009
1195
  });
1196
+ // Character — a symbol and some beats, expanded against the design graph.
1197
+ //
1198
+ // Mirrors `pinepaper_character`'s inputSchema field for field. Only `concept`
1199
+ // is required: everything else has a defensible default, and the point of the
1200
+ // tool is that a caller who knows nothing but the name of a figure gets one.
1201
+ export const CharacterBeatSchema = z.object({
1202
+ at: z.number().describe('When, in seconds'),
1203
+ channel: z.string().describe('A channel the concept declares — blink, say, headTurn, bob, tailFlick…'),
1204
+ until: z.number().optional().describe('For a channel that occupies a span rather than an instant'),
1205
+ value: z.number().optional().describe('How far, for a channel that takes a magnitude'),
1206
+ });
1207
+ export const CharacterInputSchema = z.object({
1208
+ concept: z.string().describe('Concept id from the design graph, e.g. "pp:Character" or "pp:Pigeon".'),
1209
+ at: z.object({ x: z.number().optional(), y: z.number().optional() }).optional().describe('Where the figure stands.'),
1210
+ height: z.number().optional().describe('Drawn height in pixels.'),
1211
+ id: z.string().optional().describe('Prefix for the created items (default: the concept name).'),
1212
+ style: z.string().optional().describe('Style id for the depiction (default: the concept\'s own).'),
1213
+ variant: z.string().optional().describe('Depiction variant (default: "default").'),
1214
+ durationSeconds: z.number().optional().describe('Length of the piece, so every track spans it.'),
1215
+ beats: z.array(CharacterBeatSchema).optional().describe('What happens, in order.'),
1216
+ timings: z.array(z.object({ at: z.number(), until: z.number().optional() })).optional()
1217
+ .describe('Word or syllable timings from a voice track; each entry drives one mouth opening.'),
1218
+ // THESE WERE HANDLED AND UNDECLARED. resolveCharacter reads all four and its
1219
+ // own comment says a call written the way the rest of the vocabulary reads
1220
+ // must not land dead-centre at the default size — but zod strips unknown
1221
+ // keys, so `{concept, position, scale}` did exactly that, silently.
1222
+ position: z.object({ x: z.number().optional(), y: z.number().optional() }).optional()
1223
+ .describe('Where the figure stands. A synonym for `at`, because the rest of this surface says position; `at` wins if both are sent.'),
1224
+ scale: z.number().optional()
1225
+ .describe('Size as a multiplier of the 300px default, for callers who think in scale rather than pixels. `height` wins if both are sent.'),
1226
+ palette: z.record(z.string(), z.string()).optional()
1227
+ .describe("Override the depiction's own colours: part id → colour. Only the parts you name change."),
1228
+ ink: z.string().optional()
1229
+ .describe("Ink colour for the stroked copies that make the figure read as drawn rather than filled. The style supplies its own; this overrides it."),
1230
+ });
1231
+ // Motion Capture (BVH)
1232
+ //
1233
+ // FxTool exposes two distinct operations and the difference is the whole point:
1234
+ // importBVH builds a NEW skeleton shaped like the capture file
1235
+ // retargetBVH drives an EXISTING rig — proportions stay yours, only MOTION comes from the clip
1236
+ // Angles transfer as bind-pose deltas (target.rest + (source(t) - source(rest))), so a T-posed CMU
1237
+ // rest is not slammed onto a rig with a relaxed stance. Bone matching carries the BVH child-joint
1238
+ // shift ("LeftLeg" is the thigh); anything unmatched is REPORTED, never silently half-driven.
1239
+ export const ImportMotionCaptureInputSchema = z.object({
1240
+ bvh: z.string().describe('BVH clip text (CMU / Mixamo export). Required.'),
1241
+ mode: z.enum(['import', 'retarget']).optional().default('import')
1242
+ .describe("'import' builds a new skeleton from the file; 'retarget' drives an existing rig named by skeletonId — use retarget when the character already exists, so its proportions are preserved and only the motion is taken from the capture."),
1243
+ skeletonId: z.string().optional()
1244
+ .describe("Rig to drive. REQUIRED when mode='retarget'; ignored for 'import'."),
1245
+ fps: z.number().optional().describe('Pose sampling rate. CMU records at 120fps and nobody wants 120 poses a second; 0 keeps every frame. Default 15.'),
1246
+ height: z.number().optional().describe('Skeleton height on the canvas in px (import mode). Default 320.'),
1247
+ position: z.object({ x: z.number(), y: z.number() }).optional()
1248
+ .describe('Where the ROOT joint stands (import mode). Defaults to canvas centre, 70% down.'),
1249
+ name: z.string().optional().describe('Name for the created skeleton (import mode).'),
1250
+ boneMap: z.record(z.string(), z.string()).optional()
1251
+ .describe('BVH edge name → this rig\'s bone name. Overrides the alias table when a clip uses non-standard joint names; check unmatchedSource/unmatchedTarget in the result to see what needs mapping.'),
1252
+ });
1010
1253
  // Execute Generator
1011
1254
  //
1012
1255
  // Region (added FxTool c81781c): when set, the generator draws into a clipped
@@ -1042,7 +1285,13 @@ export const GetItemsInputSchema = z.object({
1042
1285
  });
1043
1286
  // Play Timeline
1044
1287
  export const PlayTimelineInputSchema = z.object({
1045
- action: z.enum(['play', 'pause', 'stop', 'seek']),
1288
+ action: z.enum([
1289
+ 'play', 'pause', 'stop', 'seek',
1290
+ // pp:TimeScale — the rate the scene clock runs at.
1291
+ 'set_time_scale', 'get_time_scale',
1292
+ // pp:InputDrivenPlayback — progress driven by an input instead of a clock.
1293
+ 'get_progress', 'set_progress', 'bind_scroll', 'unbind_scroll', 'list_scrub_anchors',
1294
+ ]),
1046
1295
  duration: z.number().optional().describe('Duration for play action'),
1047
1296
  loop: z.boolean().optional(),
1048
1297
  time: z.number().optional().describe('Time to seek to'),
@@ -1050,6 +1299,22 @@ export const PlayTimelineInputSchema = z.object({
1050
1299
  .boolean()
1051
1300
  .optional()
1052
1301
  .describe('For seek: evaluate the scene at the exact time via app.sceneAt(t) — ticks keyframes + relations + generators deterministically (same t → same frame), not just the keyframe state. Use before a screenshot for a reproducible frame.'),
1302
+ // Deliberately unclamped: 0 freezes the clock without stopping playback, and a
1303
+ // negative rate runs the scene backwards. Both are things the engine supports
1304
+ // and a min(0) would quietly forbid.
1305
+ rate: z.number().optional()
1306
+ .describe('set_time_scale: 1 is real time, 0.5 half speed, 0 freezes without stopping, negative runs backwards. Export is unaffected — a scene watched at 0.5x still exports its real duration.'),
1307
+ progress: z.number().min(0).max(1).optional()
1308
+ .describe('set_progress: how far through the timeline, 0..1 — the unit a scrubber or a progress bar actually has.'),
1309
+ scroll: z.object({
1310
+ elementId: z.string().optional().describe('DOM id of the element to track (defaults to the canvas)'),
1311
+ start: z.string().optional().describe("Anchor where the range begins, e.g. 'top bottom' (the moment the element appears). See list_scrub_anchors."),
1312
+ end: z.string().optional().describe("Anchor where the range ends, e.g. 'bottom top' (the moment it is gone)."),
1313
+ scrub: z.number().min(0).optional()
1314
+ .describe('Seconds of smoothing — the played position chases the scroll instead of snapping to it (default 0.3). 0 snaps.'),
1315
+ range: z.array(z.number()).length(2).optional()
1316
+ .describe('[from, to] in seconds — drive only a sub-range of the timeline instead of the whole thing.'),
1317
+ }).optional().describe('bind_scroll options'),
1053
1318
  });
1054
1319
  // Canvas Control
1055
1320
  export const SetBackgroundColorInputSchema = z.object({
@@ -1187,7 +1452,7 @@ export const SceneAnimationSchema = z.object({
1187
1452
  /** Reference name of the item to animate */
1188
1453
  target: z.string().describe('Name of the item to animate'),
1189
1454
  /** Type of animation */
1190
- type: z.enum(['pulse', 'rotate', 'bounce', 'fade', 'wobble', 'slide', 'typewriter']),
1455
+ type: z.enum(['bounce', 'breathe', 'fade', 'glow', 'jelly', 'path', 'pulse', 'rotate', 'scrollDown', 'scrollLeft', 'scrollRight', 'scrollUp', 'shake', 'slideLeftRight', 'slideUpDown', 'swing', 'typewriter', 'wobble']),
1191
1456
  /** Animation speed (default: 1.0) */
1192
1457
  speed: z.number().optional(),
1193
1458
  /** Animation parameters */
@@ -1346,6 +1611,7 @@ export const CreateDiagramShapeInputSchema = z.object({
1346
1611
  * Connect items input
1347
1612
  */
1348
1613
  export const ConnectInputSchema = z.object({
1614
+ id: z.string().optional().describe('Stable connector ID. Assign your own and reuse it as `connectorId` in update/remove — the engine otherwise mints a Date.now()-based ID no caller can predict'),
1349
1615
  sourceItemId: z.string().describe('Registry ID of source item'),
1350
1616
  targetItemId: z.string().describe('Registry ID of target item'),
1351
1617
  routing: ConnectorRoutingSchema.optional().default('orthogonal'),
@@ -1363,6 +1629,7 @@ export const ConnectInputSchema = z.object({
1363
1629
  * Connect specific ports input
1364
1630
  */
1365
1631
  export const ConnectPortsInputSchema = z.object({
1632
+ id: z.string().optional().describe('Stable connector ID. Assign your own and reuse it as `connectorId` in update/remove — the engine otherwise mints a Date.now()-based ID no caller can predict'),
1366
1633
  sourceItemId: z.string().describe('Registry ID of source item'),
1367
1634
  sourcePort: PortPositionSchema.describe('Port position on source'),
1368
1635
  targetItemId: z.string().describe('Registry ID of target item'),
@@ -1561,6 +1828,10 @@ export const AgentPlatformSchema = z.enum([
1561
1828
  */
1562
1829
  export const AgentExportFormatSchema = z.enum([
1563
1830
  'svg', 'png', 'gif', 'mp4', 'webm', 'pdf',
1831
+ // AUDIO-ONLY. The soundtrack on its own, with no frames rendered — so
1832
+ // platform dimensions, framing and quality do not apply to it, and no
1833
+ // platform preset resolves to it. It has to be asked for by name.
1834
+ 'wav',
1564
1835
  ]).describe('Export file format');
1565
1836
  /**
1566
1837
  * Screenshot policy for agent jobs
@@ -1739,6 +2010,17 @@ export const AgentBatchExecuteInputSchema = z.object({
1739
2010
  * could not. This brings the two in line.
1740
2011
  */
1741
2012
  export const GIF_MAX_DURATION_S = 15;
2013
+ /**
2014
+ * Longest mp4/webm export the tool will accept, in seconds.
2015
+ *
2016
+ * Was 60. A ten-minute export is only reachable because the bytes no longer
2017
+ * cross the browser→server bridge as one base64 string: above an inline
2018
+ * ceiling the studio keeps the file in its export store and the handler pages
2019
+ * it out in chunks. On a studio with no export store the emitted code refuses
2020
+ * by name with the numbers instead, so raising this cap cannot turn into an
2021
+ * out-of-memory — it turns into a message naming the ceiling and what to do.
2022
+ */
2023
+ export const VIDEO_MAX_DURATION_S = 600;
1742
2024
  /**
1743
2025
  * Smart export input schema
1744
2026
  */
@@ -1748,10 +2030,29 @@ export const AgentExportInputSchema = z.object({
1748
2030
  quality: z.enum(['draft', 'standard', 'high']).optional().default('standard').describe('Export quality level'),
1749
2031
  includeRecommendations: z.boolean().optional().default(true).describe('Include alternative format recommendations'),
1750
2032
  framing: z.enum(['canvas', 'camera']).optional().default('canvas').describe('Output framing: "canvas" (full canvas, default) or "camera" (camera_animates first-keyframe viewport — fails if no walkthrough exists). Camera animation still drives motion within the fixed output frame.'),
1751
- duration: z.number().min(0.5).max(60).optional().default(5).describe(`Video duration in seconds for animated formats (mp4/webm/gif). Default 5. Max 60 for mp4/webm, max ${GIF_MAX_DURATION_S} for gif (GIF is not codec-bounded, so file size scales with frames × dimensions). Static formats (png/svg/pdf) ignore this.`),
1752
- estimateOnly: z.boolean().optional().default(false).describe('Preflight only: return the estimated file size for these EXACT settings and render nothing. Use before a long or high-quality export to check the size first. Modeled for mp4/webm/gif; png/pdf/svg return confidence "none" because no dimension-based model exists for them.'),
2033
+ duration: z.number().min(0.5).max(VIDEO_MAX_DURATION_S).optional().default(5).describe(`Video duration in seconds for animated formats (mp4/webm/gif). Default 5. Max ${VIDEO_MAX_DURATION_S} for mp4/webm, max ${GIF_MAX_DURATION_S} for gif (GIF is not codec-bounded, so file size scales with frames × dimensions, and it cannot stream to the export store). Static formats (png/svg/pdf) ignore this. Past roughly a minute the export is held in the studio's export store and paged back to a file rather than returned inline; a studio without that store refuses by name and says so.`),
2034
+ estimateOnly: z.boolean().optional().default(false).describe('Preflight only: return the estimated file size for these EXACT settings and render nothing. Use before a long or high-quality export to check the size first. Modeled for mp4/webm/gif; png/pdf/svg return confidence "none" because no dimension-based model exists for them; wav is reported as "exact", because uncompressed PCM size is arithmetic rather than a guess.'),
2035
+ sampleRate: z.number().int().positive().optional().describe('wav only: samples per second (default 48000). Ignored by every other format.'),
2036
+ bitDepth: z.union([z.literal(16), z.literal(32)]).optional().describe('wav only: 16 (default) or 32-bit float. Ignored by every other format.'),
1753
2037
  }).describe('Smart export options')
1754
2038
  .superRefine((val, ctx) => {
2039
+ // wav carries no picture, so the visual knobs are not merely ignored — a
2040
+ // caller who set them believes something about the output that is not
2041
+ // true. Said once, rather than silently dropped.
2042
+ if (val.format === 'wav' && val.framing === 'camera') {
2043
+ ctx.addIssue({
2044
+ code: z.ZodIssueCode.custom,
2045
+ path: ['framing'],
2046
+ message: 'framing does not apply to a wav export — it renders no frames. Drop framing, or export mp4/webm if you want the picture with the sound.',
2047
+ });
2048
+ }
2049
+ if (val.format !== 'wav' && (val.sampleRate !== undefined || val.bitDepth !== undefined)) {
2050
+ ctx.addIssue({
2051
+ code: z.ZodIssueCode.custom,
2052
+ path: ['sampleRate'],
2053
+ message: `sampleRate and bitDepth apply only to format "wav" (got "${val.format}").`,
2054
+ });
2055
+ }
1755
2056
  if (val.format === 'gif' && val.duration > GIF_MAX_DURATION_S) {
1756
2057
  ctx.addIssue({
1757
2058
  code: z.ZodIssueCode.custom,
@@ -2208,6 +2509,33 @@ export const QueryOntologyInputSchema = z.object({
2208
2509
  category: z.string().optional(),
2209
2510
  includeAbstract: z.boolean().optional().default(false),
2210
2511
  });
2512
+ // Capabilities query schema: discover and match capabilities across text styles, text effects, generators, deforms, relations, etc.
2513
+ export const QueryCapabilitiesInputSchema = z.object({
2514
+ action: z.enum(['list', 'choose', 'coverage', 'find', 'catalogue', 'studio']).default('list')
2515
+ .describe("'list' (all capabilities or filtered by kind) · 'choose' (context/mood-weighted recommendation) · 'coverage' (breakdown of indexable capabilities) · 'find' (lookup by key) · 'catalogue' (the registries the aggregate does NOT gather — see `catalogue`) · 'studio' (which engine METHODS this studio has, so you learn what will refuse BEFORE calling it)"),
2516
+ catalogue: z.enum([
2517
+ 'rig_presets', 'shader_effects', 'stroke_decorations', 'precomps', 'images',
2518
+ 'segment_edit_kinds', 'shatter_orders', 'world_meshes', 'relation_presets',
2519
+ ]).optional().describe("action 'catalogue': which registry to read. These sit OUTSIDE app.getCapabilities' aggregate — that one already covers styles, effects, deforms, entrances, animations, collages, palettes, masks, maskShapes and cutouts, and 'list' reaches all of those. Use this for the ones it does not gather: rig presets, shader effects, stroke decorations, the precomp list, the image library, segment-edit kinds, shatter orders and world meshes."),
2520
+ kind: z.union([
2521
+ z.string(),
2522
+ z.array(z.string()),
2523
+ ]).optional().describe("Filter by capability kind: 'style'|'effect'|'deform'|'entrance'|'animation'|'generator'|'collage'|'palette'|'mask'|'cutout'|'relation'"),
2524
+ mood: z.union([
2525
+ z.string(),
2526
+ z.array(z.string()),
2527
+ ]).optional().describe("For choose: target feeling ('reveal'|'triumphant'|'failure'|'calm'|'technical'|'energetic')"),
2528
+ subject: z.string().optional().describe('For choose: contextual subject keywords / prompt text to score against capability definitions'),
2529
+ avoid: z.union([
2530
+ z.string(),
2531
+ z.array(z.string()),
2532
+ ]).optional().describe('For choose: mood terms or feelings to penalize'),
2533
+ seed: z.string().optional().describe('For choose: deterministic seed string for reproducible selection'),
2534
+ key: z.string().optional().describe('For find: exact capability key to look up'),
2535
+ exclude: z.array(z.string()).optional().describe('For choose: list of capability keys to exclude from recommendation'),
2536
+ warm: z.boolean().optional()
2537
+ .describe('Load the lazy registries before answering (default true). The generator registry and the rigging/blending/deform relation rules only exist once touched, so a cold answer omits them — pass false only to re-read what is already resident, cheaply.'),
2538
+ });
2211
2539
  // =============================================================================
2212
2540
  // SCENE MANAGEMENT SCHEMAS
2213
2541
  // =============================================================================
@@ -2232,6 +2560,175 @@ export const ScenePlaybackInputSchema = z.object({
2232
2560
  index: z.number().optional(),
2233
2561
  });
2234
2562
  // =============================================================================
2563
+ // RELATIVE TIMING, STAGGERS, FLIP (GSAP's vocabulary, PinePaper's engine)
2564
+ // =============================================================================
2565
+ // pp:TimelinePosition — saying WHEN relative to something else.
2566
+ //
2567
+ // The position spec is a number or one of the string forms; it stays loose on
2568
+ // purpose, because the grammar is open ("intro+=0.5" names a caller's label) and
2569
+ // an enum here could only ever be a stale subset of it. list_forms is the
2570
+ // discoverable list.
2571
+ export const SequenceInputSchema = z.object({
2572
+ action: z.enum(['place', 'resolve', 'list_forms']).default('place')
2573
+ .describe("'place' threads a run of clips, each resolved against the ones before it · 'resolve' answers a single position · 'list_forms' returns the accepted grammar with what each form means"),
2574
+ clips: z.array(z.object({
2575
+ id: z.string().optional().describe('Your own name for the clip — echoed back on the placement'),
2576
+ duration: z.number().min(0).describe('How long the clip runs, in seconds'),
2577
+ position: z.union([z.number(), z.string()]).optional()
2578
+ .describe('Where it starts. Omit to append at the end of the timeline so far.'),
2579
+ label: z.string().optional().describe('Name this moment so later clips can position against it. A label marks where the clip STARTS.'),
2580
+ })).optional().describe('place: the run of clips, in order'),
2581
+ position: z.union([z.number(), z.string()]).optional().describe('resolve: the single position spec to resolve'),
2582
+ context: z.object({
2583
+ timelineEnd: z.number().optional().describe('Where the timeline currently ends — what a bare "+=1" counts from'),
2584
+ prevStart: z.number().optional().describe('Start of the most recently added clip — what "<" means'),
2585
+ prevEnd: z.number().optional().describe('End of the most recently added clip — what ">" means'),
2586
+ insertDuration: z.number().optional().describe('Duration of the clip being placed — the basis for a "+=25%" / "-=25%" percentage'),
2587
+ }).optional().describe('resolve: what the spec is measured against'),
2588
+ labels: z.record(z.string(), z.number()).optional().describe('Named moments in seconds, addressable as "name" or "name+=0.5"'),
2589
+ startAt: z.number().optional().describe('place: where the run begins (default 0)'),
2590
+ });
2591
+ // pp:Stagger — the shape of a delay across many items.
2592
+ const StaggerOptsSchema = z.object({
2593
+ // `each` and `amount` are ALTERNATIVES, not a pair: each fixes the gap between
2594
+ // neighbours so more items make a longer sequence; amount fixes the total so
2595
+ // more items crowd together.
2596
+ each: z.number().min(0).optional().describe('Seconds between neighbours (default 0.1). More items ⇒ a longer sequence.'),
2597
+ amount: z.number().min(0).optional().describe('Total seconds for the WHOLE run, split among the items. Overrides each — more items ⇒ they crowd together.'),
2598
+ from: z.union([
2599
+ z.enum(['start', 'end', 'center', 'edges', 'random']),
2600
+ z.number(),
2601
+ z.array(z.number()).length(2),
2602
+ ]).optional().describe("Where the stagger begins: a named origin, an item index, or [fx, fy] fractions for a grid ([0.5, 0.5] is the centre)."),
2603
+ grid: z.array(z.number()).length(2).optional().describe('[rows, columns] for a 2D stagger. Items are read row-major.'),
2604
+ axis: z.enum(['x', 'y']).optional().describe('Restrict a grid stagger to one axis.'),
2605
+ ease: z.enum(['linear', 'easeIn', 'easeOut', 'easeInOut', 'easeInCubic', 'easeOutCubic']).optional()
2606
+ .describe("Curve that distributes the START TIMES — not the items' own animation curve."),
2607
+ seed: z.number().optional().describe("from: 'random' — same seed, same shuffle (default 1)."),
2608
+ });
2609
+ export const StaggerInputSchema = z.object({
2610
+ action: z.enum(['apply', 'preview', 'list_origins']).default('apply')
2611
+ .describe("'apply' writes the delays onto real items · 'preview' returns the delays for a count without touching the canvas · 'list_origins' names the origins"),
2612
+ itemIds: z.array(z.string()).optional().describe('apply: registry ids IN THE ORDER the stagger should follow (row-major for a grid)'),
2613
+ count: z.number().int().min(0).optional().describe('preview: how many items to compute delays for'),
2614
+ opts: StaggerOptsSchema.optional().describe('Shape of the stagger'),
2615
+ });
2616
+ // pp:Flip — a transition DERIVED from a change rather than described.
2617
+ export const FlipInputSchema = z.object({
2618
+ action: z.enum(['record', 'apply']).default('record')
2619
+ .describe("'record' captures where things are NOW · 'apply' animates from that record to wherever they have since been moved"),
2620
+ itemIds: z.array(z.string()).optional()
2621
+ .describe('record: which items to capture (defaults to everything on the item layer, which is the usual case for a re-layout)'),
2622
+ duration: z.number().min(0).optional().describe('apply: seconds the transition runs (engine default)'),
2623
+ easing: z.string().optional().describe('apply: named easing for the generated keyframes'),
2624
+ enter: z.string().optional().describe('apply: entrance preset for items that appeared during the change, so they animate in rather than popping'),
2625
+ stagger: StaggerOptsSchema.optional().describe("apply: stagger the transition across the items, in the items' own order"),
2626
+ });
2627
+ // Scene graph: interactive multi-node story/quiz state graphs (cards, answers, mutex, event wiring)
2628
+ //
2629
+ // The node and opts fields below mirror what the engine's compiler actually
2630
+ // reads (js/core/SceneGraph.js). zod strips unknown keys, so a field missing
2631
+ // here is not "passed through" — it is silently dropped on the way to an engine
2632
+ // that would have honoured it. `next`, `duration`, `outcome` and `subject` were
2633
+ // each unreachable for that reason.
2634
+ const SceneGraphNodeSchema = z.object({
2635
+ id: z.string().describe('Unique node id (e.g., "q1", "win", "lose")'),
2636
+ prompt: z.string().optional().describe('Prompt or headline text for question cards'),
2637
+ text: z.string().optional().describe('Text for message/end cards'),
2638
+ kind: z.enum(['node', 'card', 'end']).optional()
2639
+ .describe("'node'/'card' (question or content) or 'end' (terminal/outcome). Anything that is not 'end' is a card."),
2640
+ answers: z.array(z.object({
2641
+ text: z.string().describe('Answer button text'),
2642
+ correct: z.boolean().optional().describe('Whether this is the correct answer (increments score when scoreItemId given)'),
2643
+ to: z.string().min(1).describe('Destination node id when clicked'),
2644
+ })).optional().describe('Interactive answer choices (for question cards)'),
2645
+ next: z.string().min(1).optional()
2646
+ .describe('Unconditional destination — a linear story beat that auto-advances instead of waiting for a click. Use INSTEAD of answers, not alongside a lone fake button.'),
2647
+ duration: z.number().positive().optional()
2648
+ .describe('Seconds this card holds before `next` fires (default 3). Ignored without `next`.'),
2649
+ outcome: z.enum(['win', 'lose']).optional()
2650
+ .describe('Force the outcome of a terminal node. Without it the engine infers win/lose from the wording, which only works in English.'),
2651
+ });
2652
+ export const SceneGraphInputSchema = z.object({
2653
+ action: z.enum(['create', 'validate']).default('create')
2654
+ .describe("'create' builds the graph on the canvas · 'validate' runs the same check WITHOUT drawing anything, returning errors, warnings, unreachable nodes and cycles"),
2655
+ graph: z.object({
2656
+ start: z.string().optional().describe('Node id of the initial active card (defaults to the first node)'),
2657
+ nodes: z.array(SceneGraphNodeSchema).min(1).describe('Array of scene graph nodes'),
2658
+ })
2659
+ // Structural integrity, checked HERE because in code mode there is no engine
2660
+ // to catch it: the snippet is handed to the caller and fails silently later.
2661
+ // Duplicate ids in particular are invisible engine-side — `indexNodes` keys a
2662
+ // Map by id, so the second node quietly replaces the first.
2663
+ .superRefine((graph, ctx) => {
2664
+ const seen = new Set();
2665
+ graph.nodes.forEach((node, i) => {
2666
+ if (seen.has(node.id)) {
2667
+ ctx.addIssue({
2668
+ code: z.ZodIssueCode.custom,
2669
+ path: ['nodes', i, 'id'],
2670
+ message: `duplicate node id "${node.id}" — the later node silently replaces the earlier one`,
2671
+ });
2672
+ }
2673
+ seen.add(node.id);
2674
+ });
2675
+ const has = (id) => seen.has(id);
2676
+ if (graph.start !== undefined && !has(graph.start)) {
2677
+ ctx.addIssue({
2678
+ code: z.ZodIssueCode.custom,
2679
+ path: ['start'],
2680
+ message: `start node "${graph.start}" does not exist`,
2681
+ });
2682
+ }
2683
+ graph.nodes.forEach((node, i) => {
2684
+ (node.answers || []).forEach((answer, a) => {
2685
+ if (!has(answer.to)) {
2686
+ ctx.addIssue({
2687
+ code: z.ZodIssueCode.custom,
2688
+ path: ['nodes', i, 'answers', a, 'to'],
2689
+ message: `node "${node.id}" points at "${answer.to}", which does not exist — clicking it would do nothing`,
2690
+ });
2691
+ }
2692
+ });
2693
+ if (node.next !== undefined && !has(node.next)) {
2694
+ ctx.addIssue({
2695
+ code: z.ZodIssueCode.custom,
2696
+ path: ['nodes', i, 'next'],
2697
+ message: `node "${node.id}" advances to "${node.next}", which does not exist`,
2698
+ });
2699
+ }
2700
+ // Mirrors the engine's own refusal: a non-terminal card with no edge is
2701
+ // a dead end the player can never leave.
2702
+ if (node.kind !== 'end' && !node.answers?.length && node.next === undefined) {
2703
+ ctx.addIssue({
2704
+ code: z.ZodIssueCode.custom,
2705
+ path: ['nodes', i],
2706
+ message: `node "${node.id}" is not terminal but has no way out — give it answers, a next, or kind: 'end'`,
2707
+ });
2708
+ }
2709
+ });
2710
+ })
2711
+ .describe('The declarative scene graph definition'),
2712
+ opts: z.object({
2713
+ width: z.number().optional().describe('Layout width (defaults to view width or canvas width)'),
2714
+ height: z.number().optional().describe('Layout height (defaults to view height or canvas height)'),
2715
+ centerX: z.number().optional().describe('Center X position for card stack'),
2716
+ centerY: z.number().optional().describe('Center Y position for card stack'),
2717
+ groupName: z.string().optional().describe('Prefix / seed namespace for generated IDs and effect selection'),
2718
+ scoreItemId: z.string().optional().describe('Optional scoreboard text item id to increment on correct answers'),
2719
+ subject: z.string().optional()
2720
+ .describe("Extra context folded into each card's text-effect selection — the theme of the piece, e.g. 'space exploration'."),
2721
+ plain: z.boolean().optional().describe('Opt out of rich effects/entrances for a bare structural graph'),
2722
+ style: z.object({
2723
+ panelColor: z.string().optional().describe('Background panel fill color (default #1E1B4B)'),
2724
+ textColor: z.string().optional().describe('Headline/prompt text color (default #F8FAFC)'),
2725
+ answerColor: z.string().optional().describe('Answer text color (default #93C5FD)'),
2726
+ headlineSize: z.number().optional().describe('Font size for headline/prompt (default 34)'),
2727
+ bodySize: z.number().optional().describe('Font size for body/answers (default 24)'),
2728
+ }).optional().describe('Visual styling options for generated cards and text'),
2729
+ }).optional().describe('Layout, style, and scoring options'),
2730
+ });
2731
+ // =============================================================================
2235
2732
  // SELECTION, TRANSFORM & HISTORY TOOLS
2236
2733
  // =============================================================================
2237
2734
  export const SelectionInputSchema = z.object({
@@ -2329,6 +2826,28 @@ export const ProvenanceInputSchema = z.object({
2329
2826
  sourceRef: z.string().optional(),
2330
2827
  meta: z.record(z.string(), z.unknown()).optional(),
2331
2828
  });
2829
+ /**
2830
+ * The stitches the thread medium publishes — ONE list, because it had four copies.
2831
+ *
2832
+ * Mirrors FxTool's `STITCH_OPS` in js/core/ThreadPainting.js — ENFORCED BY
2833
+ * src/__tests__/unit/preflight-gate-parity.test.ts against the fixture
2834
+ * src/__tests__/fixtures/engine-stitches.txt, both directions, with a
2835
+ * guard-the-guard that plants a seventh stitch and asserts the comparison
2836
+ * fails. A parity claim that cites the test enforcing it is checkable in one
2837
+ * command; one that does not is a memory of someone having checked once, and
2838
+ * this sentence was that memory for six commits. The table is
2839
+ * vocabulary `applyThreadPainting` dispatches on; it refuses an unknown name
2840
+ * rather than falling through to a default fill.
2841
+ *
2842
+ * There were four copies of this list here — two Zod enums and their two JSON
2843
+ * mirrors — and fixing one left `pinepaper_compose` unable to ask for two
2844
+ * stitches `apply_thread` could. The engine had the identical split for the
2845
+ * identical reason: `MEDIA.thread.stitches` was hardcoded to the same stale
2846
+ * four while STITCH_OPS published six (FxTool 6aaa9069). Two repos, four
2847
+ * copies, one list, drifted the same direction on the same day — so it is
2848
+ * declared once here and spread everywhere it is needed.
2849
+ */
2850
+ export const THREAD_STITCHES = ['longAndShort', 'satin', 'seed', 'stem', 'runningSeam', 'crossStitch'];
2332
2851
  export const SceneDiffInputSchema = z.object({
2333
2852
  /** 'history' compares two undo states; 'version' compares live vs a saved version. */
2334
2853
  action: z.enum(['history', 'version']),
@@ -2364,6 +2883,24 @@ export const ComposeInputSchema = z.object({
2364
2883
  audio: z.string().optional(),
2365
2884
  /** With audio: snap to an even tempo grid instead of raw onsets. */
2366
2885
  grid: z.boolean().optional(),
2886
+ /**
2887
+ * WHICH DESIGN LANGUAGE. Resolves the craft — gutter, margin, hue budget,
2888
+ * type scale, alignment discipline — so those move together instead of being
2889
+ * chosen one at a time. A default, not a handcuff: an explicit `craft` still
2890
+ * wins. naive | playful | poster | editorial | technical.
2891
+ */
2892
+ register: z.string().optional().describe('Design register — naive, playful, poster, editorial, technical. Sets the craft; an explicit `craft` still overrides it.'),
2893
+ level: z.number().int().min(1).max(4).optional().describe('Craft level 1-4 WITHIN the register (sketch, competent, refined, art-directed). Climbing never widens a budget — restraint is the direction of craft.'),
2894
+ /**
2895
+ * WHAT MAKES THE MARKS. Only media a whole composition can be rendered in
2896
+ * are accepted: `vector` (what composing already produces) and `thread`
2897
+ * (needlepainting, stitched after the arrangement). The rest are refused
2898
+ * with the medium's own reason rather than composed as flat shapes in its
2899
+ * colours.
2900
+ */
2901
+ medium: z.enum(['vector', 'thread']).optional().describe("Render the composition in a medium. 'thread' stitches every closed path; photographs and text CANNOT be stitched and come back in `medium.skipped` with the reason, so read that count — a composition can render completely and be entirely unstitched."),
2902
+ stitch: z.enum(THREAD_STITCHES).optional().describe('Which stitch, when medium is thread. Default longAndShort. The same six pinepaper_design_medium offers — call its list_stitches for each one.'),
2903
+ stitchBudget: z.number().int().min(200).max(20000).optional().describe('Total marks across the composition (default 6000). The stitch is scaled to fit rather than the fill being truncated — a half-stitched shape looks broken, coarser thread does not.'),
2367
2904
  });
2368
2905
  export const AudioBeatsInputSchema = z.object({
2369
2906
  action: z.enum(['analyze', 'animate_to_beat']),
@@ -2743,8 +3280,17 @@ export const MediaInputSchema = z.object({
2743
3280
  * platform impossibility, not an omission.
2744
3281
  */
2745
3282
  export const TextStyleInputSchema = z.object({
2746
- action: z.enum(['apply_style', 'set_font_axes', 'list_styles'])
2747
- .describe("'apply_style' (stacked-layer display style) · 'set_font_axes' (variable-font weight/width/slant) · 'list_styles' (styles + palettes + axes, for pickers)"),
3283
+ action: z.enum(['apply_style', 'set_font_axes', 'list_styles', 'cursive', 'wrap', 'unwrap', 'to_collage'])
3284
+ .describe("'apply_style' (stacked-layer display style) · 'set_font_axes' (variable-font weight/width/slant) · 'list_styles' (styles + palettes + axes, for pickers) · 'cursive' (draw text as STROKED handwriting — a path, not a glyph, so it can be drawn on over time) · 'wrap' / 'unwrap' (break a text item to a width, reversibly) · 'to_collage' (turn an EXISTING text item into a letter collage, keeping its place)"),
3285
+ text: z.string().optional().describe('cursive: the words to write. to_collage: the text to build the collage from, if it differs from the item.'),
3286
+ maxWidth: z.number().optional().describe('wrap: the width to break at, in canvas units. unwrap restores the original single line, so this is not destructive.'),
3287
+ cursiveOptions: z.object({
3288
+ x: z.number().optional(), y: z.number().optional(),
3289
+ scale: z.number().optional().describe('Size multiplier (default 1).'),
3290
+ strokeColor: z.string().optional().describe("Ink colour (default '#ffffff')."),
3291
+ strokeWidth: z.number().optional().describe('Pen width (default 2).'),
3292
+ }).optional().describe('cursive: where and how it is written. The result is a STROKED PATH, so pinepaper_animate\'s draw-on and pinepaper_path\'s outline_stroke both apply to it — which is the reason to prefer it over a script font.'),
3293
+ collageOptions: z.record(z.string(), z.unknown()).optional().describe('to_collage: style, palette and the rest, same vocabulary as pinepaper_create_letter_collage.'),
2748
3294
  itemId: z.string().optional().describe('Text item id — apply_style / set_font_axes.'),
2749
3295
  styleKey: z.string().optional().describe("apply_style: style name from list_styles (e.g. 'stacked', 'arcade' — the pixel/arcade styles suggest their own face)."),
2750
3296
  palette: z.union([z.string(), z.array(z.string())]).optional().describe('apply_style: a named colourway or an explicit color array.'),
@@ -2760,6 +3306,104 @@ export const TextStyleInputSchema = z.object({
2760
3306
  })
2761
3307
  .refine((v) => v.action !== 'apply_style' || (!!v.itemId && !!v.styleKey), { message: 'apply_style requires itemId and styleKey', path: ['styleKey'] })
2762
3308
  .refine((v) => v.action !== 'set_font_axes' || (!!v.itemId && !!v.axes && Object.keys(v.axes).length > 0), { message: 'set_font_axes requires itemId and at least one axis', path: ['axes'] });
3309
+ /**
3310
+ * pinepaper_text_effect — the 37 character-level text effects
3311
+ * (terminaltexteffects' vocabulary, reimplemented in FxTool from source).
3312
+ *
3313
+ * The contract that separates this from `pinepaper_text_style`, and the one
3314
+ * thing a caller must know: this tool EXPLODES the text item into one item per
3315
+ * character and REMOVES the original. `text_style` ADOPTS the text's registry
3316
+ * id; this DESTROYS it. Relations, keyframes and agent handles pointing at the
3317
+ * source id die with it — `keepSource: true` leaves the original in place if
3318
+ * they matter.
3319
+ *
3320
+ * `effect` is a free string, not an enum: the engine owns the vocabulary, its
3321
+ * own error names every valid key, and `action: 'list'` is authoritative. An
3322
+ * enum here would pin the server to today's 37 and go stale on the 38th.
3323
+ *
3324
+ * The planner is pure and emits KEYFRAMES, so the result is ordinary animated
3325
+ * items — it scrubs, survives undo/session restore, and exports through the
3326
+ * existing MP4/SMIL/Lottie paths rather than needing a per-exporter animator.
3327
+ */
3328
+ /**
3329
+ * The MEDIUM axis — what makes the marks, as distinct from which design
3330
+ * language (register) and how well executed (level).
3331
+ *
3332
+ * Every medium declares a fidelity, and the tool refuses an `absent` one with
3333
+ * the reason rather than producing flat shapes in its colours. Ask
3334
+ * `list_media` before promising a medium in prose.
3335
+ */
3336
+ export const DesignMediumInputSchema = z.object({
3337
+ action: z.enum(['list_media', 'resolve', 'list_stitches', 'apply_thread', 'apply_hatch', 'list_flow_fields', 'list_hatch_options'])
3338
+ .describe("'list_media' (7 media with fidelity + limitation) · 'resolve' (can this medium be made here, and how honestly) · 'list_stitches' · 'apply_thread' (render an item in thread) · 'apply_hatch' (rule an item with hatching — value through line density) · 'list_flow_fields' · 'list_hatch_options'"),
3339
+ medium: z.string().optional().describe("resolve: medium key — vector, thread, hatch, watercolor, ink, cutPaper, charcoal, oil, encaustic. Call list_media for the live set with each one's fidelity; the catalogue grows."),
3340
+ itemId: z.string().optional().describe('apply_thread / apply_hatch: a closed path, compound path, or a group of them. Its own silhouette is the region and its fill is the ink colour.'),
3341
+ stitch: z.enum(THREAD_STITCHES).optional()
3342
+ .describe("Which stitch, when the medium is thread. Default longAndShort. These six are THE_STITCH_OPS the engine publishes — call 'list_stitches' for each one's description and its own parameters. An unknown name is refused by the engine rather than quietly stitched as a default fill."),
3343
+ field: z.object({
3344
+ kind: z.enum(['radial', 'spine', 'constant']),
3345
+ cx: z.number().optional(), cy: z.number().optional(),
3346
+ angle: z.number().optional(),
3347
+ spine: z.array(PositionSchema).optional(),
3348
+ across: z.boolean().optional(),
3349
+ }).optional().describe("apply_thread: the direction field — the thing that makes it needlepainting rather than hatching. Default radial from the shape centre. 'spine' runs stitches along a midrib or feather shaft; 'constant' is flat hatch."),
3350
+ stitchLen: z.number().positive().optional().describe('apply_thread: nominal stitch length px (default 18).'),
3351
+ rowGap: z.number().positive().optional().describe('apply_thread: row spacing px. Defaults from the thread width so the rows abut; setting it wider is what makes a fill read as hatching.'),
3352
+ variance: z.number().min(0).max(1).optional().describe('apply_thread: length jitter 0..1 (default 0.35) — the variance IS the long-and-short shading.'),
3353
+ width: z.number().positive().optional().describe('apply_thread: thread width px (default 2).'),
3354
+ sheen: z.boolean().optional().describe('apply_thread: draw the highlight along each stitch (default true) — thread is not matte.'),
3355
+ color: z.string().optional().describe("apply_thread: thread colour, defaulting to the item's own fill."),
3356
+ seed: z.number().int().optional().describe('apply_thread: PRNG seed (default 1). Same seed stitches the same way.'),
3357
+ count: z.number().int().positive().optional().describe('apply_thread: stitch count for seed/satin.'),
3358
+ // THE OPTIONS THE ENGINE READS AND NOTHING NAMED. ThreadPainting.paint reads
3359
+ // every one of these; apply_thread passed ten of its eighteen, so eight
3360
+ // controls existed and were unreachable — the same gap that made the stick
3361
+ // kit's options source-only.
3362
+ slant: z.number().optional().describe('apply_thread: stitch slant in DEGREES — satin (default 0) and stem (default 22). A satin fill laid at a slant is what separates embroidery from ruling.'),
3363
+ overlap: z.number().min(0).optional().describe('apply_thread: how far consecutive stem stitches overlap (default 0.4) — what makes a stem outline read as rope rather than a dashed line.'),
3364
+ stagger: z.number().min(0).max(1).optional().describe('apply_thread: offsets alternate longAndShort rows (default 0.5) — stops stitch ends lining up into seams down the fill.'),
3365
+ gapLen: z.number().positive().optional().describe('apply_thread: gap between runningSeam stitches, px (default 4). With stitchLen, this is the dash rhythm of a machine seam.'),
3366
+ gridSize: z.number().positive().optional().describe('apply_thread: crossStitch grid pitch, px (default 12) — the size of each X, and so the resolution of the whole fill.'),
3367
+ roughness: z.number().min(0).optional()
3368
+ .describe('apply_thread: hand wobble. Jitters every point of every stitch after the engine lays them, so the fill reads as sewn rather than plotted. 0 is machine-exact. Reproducible for a given seed. This one is applied by the tool, not the engine.'),
3369
+ // --- Hatching (pp: the p5.brush marks, as vector) ---
3370
+ //
3371
+ // `field` above is the THREAD direction field (radial/spine/constant) and is
3372
+ // a different thing from `flowField` here (hand/curved/waves/…). Separate
3373
+ // names because one steers stitches around a form and the other bends a
3374
+ // ruling line the way a hand would drift; sharing a key would silently apply
3375
+ // one where the other was meant.
3376
+ distance: z.number().positive().optional()
3377
+ .describe('apply_hatch: spacing between lines in px (default 6). THIS IS THE VALUE CONTROL — the same shape at 6px and 3px reads as light and dark with no change of colour.'),
3378
+ angle: z.number().optional().describe('apply_hatch: ruling angle in DEGREES (default 45).'),
3379
+ gradient: z.number().min(0).max(1).optional()
3380
+ .describe('apply_hatch: 0..1 spacing growth per line (default 0, even). Non-zero makes the density fall off across the shape — a shaded ramp rather than a flat tone.'),
3381
+ rand: z.number().min(0).max(1).optional()
3382
+ .describe('apply_hatch: jitter as a fraction of distance (default 0) — takes the mechanical evenness off.'),
3383
+ continuous: z.boolean().optional()
3384
+ .describe('apply_hatch: join the lines into ONE serpentine path (default false) — a pen that never leaves the paper, and a single item instead of many.'),
3385
+ flowField: z.enum(['hand', 'curved', 'zigzag', 'waves', 'seabed', 'spiral', 'columns']).optional()
3386
+ .describe('apply_hatch: bend each line along a flow field. The straight scanline is the ruling; the field is what makes it read as drawn rather than printed. See list_flow_fields.'),
3387
+ t: z.number().optional().describe('apply_hatch: flow-field time offset — advances the field without changing the seed.'),
3388
+ })
3389
+ .refine((v) => v.action !== 'apply_thread' || !!v.itemId, { message: 'apply_thread requires itemId', path: ['itemId'] })
3390
+ .refine((v) => v.action !== 'resolve' || !!v.medium, { message: 'resolve requires medium', path: ['medium'] })
3391
+ .refine((v) => v.action !== 'apply_hatch' || !!v.itemId, { message: 'apply_hatch requires itemId', path: ['itemId'] });
3392
+ export const TextEffectInputSchema = z.object({
3393
+ action: z.enum(['apply', 'list'])
3394
+ .describe("'apply' (explode a text item into animated characters) · 'list' (the 37 effects with key/label/definition, for a picker)"),
3395
+ itemId: z.string().optional().describe('apply: the text item to animate. Must be a text item with non-whitespace content.'),
3396
+ effect: z.string().optional().describe("apply: effect key from 'list' (e.g. scattered, decrypt, matrix, burn, fireworks, rain, blackhole)."),
3397
+ duration: z.number().positive().optional().describe('apply: seconds for the whole effect (default 2.5). Every effect ends AT REST, so this is the settle time.'),
3398
+ seed: z.number().int().optional().describe('apply: PRNG seed (default 1). Same seed + same text = identical animation, so exports are reproducible.'),
3399
+ gradient: z.boolean().optional().describe("apply: false keeps each character's authored fill. Default TRUE — the upstream effects all end by painting the block with a gradient, which is what their reference frames show."),
3400
+ gradientStops: z.array(z.string()).optional().describe('apply: hex colors overriding the effect\'s own gradient.'),
3401
+ gradientDirection: z.enum(['vertical', 'horizontal', 'radial', 'diagonal']).optional().describe('apply: gradient axis across the text block.'),
3402
+ gradientSteps: z.number().int().positive().optional().describe('apply: quantization steps in the gradient ramp.'),
3403
+ keepSource: z.boolean().optional().describe('apply: leave the original text item in place instead of removing it — the escape hatch when relations/keyframes reference its id.'),
3404
+ options: z.record(z.string(), z.unknown()).optional().describe('apply: per-effect knobs, passed through verbatim (each of the 37 has its own). Spread FIRST, so named fields above win on collision.'),
3405
+ })
3406
+ .refine((v) => v.action !== 'apply' || (!!v.itemId && !!v.effect), { message: 'apply requires itemId and effect', path: ['effect'] });
2763
3407
  /**
2764
3408
  * pinepaper_shatter_image — split a raster into a grid of per-tile rasters.
2765
3409
  * Deliberately INERT on its own: the pieces sit exactly where the picture was
@@ -2839,8 +3483,19 @@ export const GameInputSchema = z.object({
2839
3483
  * itself — the tool does not restate it, so it cannot drift.
2840
3484
  */
2841
3485
  export const World3DInputSchema = z.object({
2842
- action: z.enum(['create', 'describe', 'configure', 'add_actor', 'remove_actor', 'list_actors', 'set_actor_pose', 'set_camera', 'add_object', 'remove_object', 'remove_world'])
2843
- .describe("'create' (a preset world) · 'describe' (every parameter with type/range — CALL THIS before configure) · 'configure' (live deep-merge patch, schema-validated) · actor ops · 'set_camera' (follow|fixed|orbit) · object ops · 'remove_world'"),
3486
+ action: z.enum([
3487
+ 'create', 'describe', 'configure', 'add_actor', 'remove_actor', 'list_actors', 'set_actor_pose',
3488
+ 'set_camera', 'add_object', 'remove_object', 'remove_world',
3489
+ 'extrude_path', 'lathe_path', 'list_meshes', 'remove_mesh',
3490
+ 'add_light', 'set_light', 'remove_light', 'list_lights',
3491
+ 'add_material', 'set_material', 'remove_material', 'list_materials',
3492
+ 'import_obj', 'import_gltf', 'list_mesh_clips', 'set_mesh_clip',
3493
+ 'set_nav_target', 'get_nav_target', 'ground_height', 'raycast',
3494
+ 'dolly_camera', 'pan_camera', 'world_to_canvas', 'canvas_to_ground',
3495
+ 'add_body', 'remove_body', 'list_bodies', 'step_physics', 'impulse', 'set_velocity', 'fire_projectile',
3496
+ 'line_of_sight', 'set_mesh_instances',
3497
+ ])
3498
+ .describe("'create' (a preset world) · 'describe' (every parameter with type/range — CALL THIS before configure) · 'configure' (live deep-merge patch, schema-validated) · actor ops · 'set_camera' (follow|fixed|orbit) · object ops · 'remove_world' · MESH AUTHORING: 'extrude_path' / 'lathe_path' turn a canvas path into real geometry, 'list_meshes', 'remove_mesh' · 'add_light' / 'set_light' / 'remove_light' / 'list_lights' (at most 8 point lights) · 'add_material' / 'set_material' / 'remove_material' / 'list_materials'"),
2844
3499
  spec: z.union([z.string(), z.record(z.string(), z.unknown())]).optional().describe("create: a preset id ('forest', 'snowMountain', …) or a full world spec object."),
2845
3500
  character: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional().describe('create: include the walkable character (true/config).'),
2846
3501
  patch: z.record(z.string(), z.unknown()).optional().describe('configure: partial world spec, deep-merged and validated — a wrong key errors naming the right one.'),
@@ -2852,18 +3507,372 @@ export const World3DInputSchema = z.object({
2852
3507
  live: z.boolean().optional().describe('add_actor: re-rasterize the item as it animates — a rigged character PERFORMS in the world instead of standing there as a photograph of itself.'),
2853
3508
  pose: z.record(z.string(), z.unknown()).optional().describe('set_actor_pose: { x?, z?, angle?, … } — the setter a timeline or agent drives.'),
2854
3509
  camera: z.record(z.string(), z.unknown()).optional().describe("set_camera: { mode: 'follow'|'fixed'|'orbit', target?, radius?, speed?, eye?, lookAt? }."),
2855
- object: z.record(z.string(), z.unknown()).optional().describe('add_object: { x, z, height?, color?, y? (defaults to sitting on the terrain) }.'),
3510
+ object: z.record(z.string(), z.unknown()).optional().describe("add_object: { x, z, height?, color?, y? } (defaults to sitting on the terrain). metalness and roughness are ACCEPTED AND IGNORED here: a plain object is drawn by the prop shader, which has no such uniform, so they are stored and listed and never reach a pixel. They render on the MESH path only — 'extrude_path' / 'lathe_path' and imported geometry. Use a material there instead of setting them here and seeing nothing."),
2856
3511
  objectId: z.string().optional().describe('remove_object: the object id.'),
3512
+ // --- Mesh authoring: a canvas path becomes real geometry -----------------
3513
+ pathId: z.string().optional().describe("extrude_path / lathe_path: the canvas path to turn into geometry. Extrude sweeps it along depth; lathe revolves its profile around the Y axis. The path is unchanged — the mesh is new."),
3514
+ mesh: z.object({
3515
+ id: z.string().optional().describe('Mesh id (generated if omitted).'),
3516
+ depth: z.number().optional().describe('extrude_path: how far to sweep, in world units.'),
3517
+ unitsPerPixel: z.number().optional().describe('Canvas pixels → world units. The whole scale relationship between the drawing and the world.'),
3518
+ caps: z.boolean().optional().describe('extrude_path: close the two ends. A capped extrude is a solid; an uncapped one is a ribbon.'),
3519
+ segments: z.number().optional().describe('extrude_path: segments along the sweep · lathe_path: segments around the revolution.'),
3520
+ arc: z.number().optional().describe('lathe_path: how far round to revolve, in degrees. 360 is a full solid of revolution; less leaves it open.'),
3521
+ flatness: z.number().optional().describe('Curve flattening tolerance when the path is sampled into rings — smaller is finer and heavier.'),
3522
+ x: z.number().optional().describe('Placement in world space.'),
3523
+ y: z.number().optional().describe('Placement in world space (omit to let it sit where the terrain puts it).'),
3524
+ z: z.number().optional().describe('Placement in world space.'),
3525
+ rotY: z.number().optional().describe('Rotation about the Y axis, in RADIANS — the value is stored raw and fed straight to cos()/sin() in the shader, with no conversion anywhere on the path. This is an exception to the degrees convention every other angle in this surface follows; pass rotYDegrees instead if you would rather write degrees. (`arc` on lathe_path IS degrees — the engine converts that one.)'),
3526
+ rotYDegrees: z.number().optional().describe('Rotation about the Y axis in degrees, converted to radians for you. Convenience only — the engine and the mesh provenance record both speak rotY in radians, so a round-tripped capture uses rotY.'),
3527
+ scale: z.number().optional().describe('Uniform scale applied after the geometry is built.'),
3528
+ doubleSided: z.boolean().optional().describe('Draw back faces too. An uncapped extrude usually wants this.'),
3529
+ }).optional().describe("extrude_path / lathe_path options. These field names ARE the ones the engine records as the mesh's provenance, so a mesh captured in a design graph can be rebuilt by spreading its meshProvenance.opts straight back in here."),
3530
+ meshId: z.string().optional().describe('remove_mesh: the mesh id.'),
3531
+ // --- Lights ---------------------------------------------------------------
3532
+ light: z.object({
3533
+ id: z.string().optional().describe('Light id (generated if omitted).'),
3534
+ x: z.number().optional().describe('World-space x (default 0).'),
3535
+ y: z.number().optional().describe('World-space y (default 3 — above the ground, not on it).'),
3536
+ z: z.number().optional().describe('World-space z (default 0).'),
3537
+ color: z.array(z.number()).length(3).optional().describe('Linear RGB, each component ≥ 0 (default [1, 0.86, 0.62], a warm lamp).'),
3538
+ intensity: z.number().optional().describe('Brightness (default 6).'),
3539
+ range: z.number().optional().describe('How far the light reaches, in world units (default 20).'),
3540
+ }).optional().describe('add_light / set_light: the light spec. At most 8 point lights exist at once — the shader array is fixed-size, so a ninth is refused by name rather than ignored.'),
3541
+ lightId: z.string().optional().describe('set_light / remove_light: the light id.'),
3542
+ // --- Materials ------------------------------------------------------------
3543
+ material: z.object({
3544
+ id: z.string().optional().describe('Material id (generated if omitted).'),
3545
+ color: z.array(z.number()).length(3).optional().describe('Base colour, linear RGB (default [0.8, 0.8, 0.82]).'),
3546
+ emissive: z.array(z.number()).length(3).optional().describe('Light the surface emits regardless of the scene (default [0, 0, 0]).'),
3547
+ metalness: z.number().min(0).max(1).optional().describe('0 dielectric → 1 metal.'),
3548
+ roughness: z.number().min(0).max(1).optional().describe('0 mirror → 1 fully diffuse. Floored at 0.045: a true zero is a numerically unstable mirror.'),
3549
+ emissiveIntensity: z.number().min(0).optional().describe('Scales `emissive` before the frame is built, so it reaches every path that draws emissive.'),
3550
+ clearcoat: z.number().min(0).max(1).optional().describe('A lacquer layer over the base — car paint, varnished wood.'),
3551
+ clearcoatRoughness: z.number().min(0).max(1).optional().describe("The coat's own roughness. NOT floored like the base, so a mirror-smooth lacquer stays expressible."),
3552
+ sheenColor: z.array(z.number()).length(3).optional().describe('Retroreflective sheen colour — cloth, velvet.'),
3553
+ sheenRoughness: z.number().min(0).max(1).optional().describe('How broad the sheen lobe is.'),
3554
+ }).optional().describe("add_material / set_material: a NAMED, SHARED surface referenced by many objects, so one edit restyles all of them. metalness and roughness reach the shader on the MESH path only. There are no aoMapIntensity / normalScale / envMapIntensity knobs: each scales a map that does not exist, and a knob that scales nothing is worse than a missing one."),
3555
+ materialId: z.string().optional().describe('set_material / remove_material: the material id.'),
3556
+ // --- Import ---------------------------------------------------------------
3557
+ source: z.string().optional().describe('import_obj: the OBJ file TEXT. import_gltf: a URL, or a data: URI of the .glb bytes.'),
3558
+ importOptions: z.record(z.string(), z.unknown()).optional().describe('import_obj / import_gltf: placement and scale options, passed through to the engine.'),
3559
+ clip: z.string().optional().describe('set_mesh_clip: which imported animation clip a skinned mesh plays.'),
3560
+ crossfade: z.number().optional().describe('set_mesh_clip: seconds to blend out of the clip that was playing. The blend happens in LOCAL pose space — a palette cannot be blended after the fact.'),
3561
+ // --- Navigation, picking and the camera ----------------------------------
3562
+ navTarget: z.enum(['2d', '3d']).optional().describe("set_nav_target: which layer the pointer drives — '2d' the Paper canvas, '3d' the world."),
3563
+ point: z.object({ x: z.number(), y: z.number(), z: z.number().optional() }).optional()
3564
+ .describe('ground_height / canvas_to_ground / world_to_canvas: the point to convert. ground_height and canvas_to_ground read x and y (canvas) or x and z (world); world_to_canvas takes a world point.'),
3565
+ origin: z.array(z.number()).length(3).optional().describe('raycast: ray origin in world space, [x, y, z].'),
3566
+ direction: z.array(z.number()).length(3).optional().describe('raycast: ray direction in world space, [x, y, z]. Need not be normalised.'),
3567
+ multiplier: z.number().optional().describe('dolly_camera: greater than 1 moves away, less than 1 moves closer.'),
3568
+ dx: z.number().optional().describe('pan_camera: horizontal pan in SCREEN pixels.'),
3569
+ dy: z.number().optional().describe('pan_camera: vertical pan in SCREEN pixels.'),
3570
+ // --- Physics in the world -------------------------------------------------
3571
+ body: z.record(z.string(), z.unknown()).optional()
3572
+ .describe('add_body / fire_projectile: the body spec. Deliberately NARROW — a sphere or a capsule under gravity, colliding with the world (terrain, objects, actors, imported meshes). There is no stacking solver and no joints; for those use pinepaper_physics, which is the 2D world.'),
3573
+ bodyId: z.string().optional().describe('remove_body / impulse / set_velocity: the body id.'),
3574
+ vector: z.array(z.number()).length(3).optional().describe('impulse: the impulse to apply, [x, y, z]. set_velocity: the velocity to set, [x, y, z].'),
3575
+ dt: z.number().optional().describe('step_physics: seconds to advance. Returns the contacts made and the bodies removed in that step.'),
3576
+ from: z.array(z.number()).length(3).optional().describe('line_of_sight: the eye point, [x, y, z].'),
3577
+ to: z.array(z.number()).length(3).optional().describe('line_of_sight: the target point, [x, y, z].'),
3578
+ instances: z.array(z.array(z.number())).optional()
3579
+ .describe('set_mesh_instances: one entry per copy, six numbers each — x, y, z, scale, rotY (RADIANS), and a free variant a shader may read for per-instance colour or phase.'),
2857
3580
  })
2858
3581
  .refine((v) => v.action !== 'configure' || !!v.patch, { message: 'configure requires patch', path: ['patch'] })
2859
3582
  .refine((v) => !['remove_actor', 'set_actor_pose'].includes(v.action) || !!v.actorId, { message: 'this action requires actorId', path: ['actorId'] })
2860
3583
  .refine((v) => v.action !== 'set_actor_pose' || !!v.pose, { message: 'set_actor_pose requires pose', path: ['pose'] })
2861
3584
  .refine((v) => v.action !== 'set_camera' || !!v.camera, { message: 'set_camera requires camera', path: ['camera'] })
2862
3585
  .refine((v) => v.action !== 'add_object' || !!v.object, { message: 'add_object requires object', path: ['object'] })
2863
- .refine((v) => v.action !== 'remove_object' || !!v.objectId, { message: 'remove_object requires objectId', path: ['objectId'] });
3586
+ .refine((v) => v.action !== 'remove_object' || !!v.objectId, { message: 'remove_object requires objectId', path: ['objectId'] })
3587
+ .refine((v) => !['extrude_path', 'lathe_path'].includes(v.action) || !!v.pathId, { message: 'this action requires pathId — the canvas path to turn into geometry', path: ['pathId'] })
3588
+ .refine((v) => v.action !== 'remove_mesh' || !!v.meshId, { message: 'remove_mesh requires meshId', path: ['meshId'] })
3589
+ .refine((v) => v.action !== 'add_light' || !!v.light, { message: 'add_light requires light', path: ['light'] })
3590
+ .refine((v) => v.action !== 'set_light' || (!!v.lightId && !!v.light), { message: 'set_light requires lightId and light', path: ['lightId'] })
3591
+ .refine((v) => v.action !== 'remove_light' || !!v.lightId, { message: 'remove_light requires lightId', path: ['lightId'] })
3592
+ .refine((v) => v.action !== 'add_material' || !!v.material, { message: 'add_material requires material', path: ['material'] })
3593
+ .refine((v) => v.action !== 'set_material' || (!!v.materialId && !!v.material), { message: 'set_material requires materialId and material', path: ['materialId'] })
3594
+ .refine((v) => v.action !== 'remove_material' || !!v.materialId, { message: 'remove_material requires materialId', path: ['materialId'] })
3595
+ .refine((v) => !['import_obj', 'import_gltf'].includes(v.action) || !!v.source, { message: 'this action requires source', path: ['source'] })
3596
+ .refine((v) => !['list_mesh_clips', 'set_mesh_clip'].includes(v.action) || !!v.meshId, { message: 'this action requires meshId', path: ['meshId'] })
3597
+ .refine((v) => v.action !== 'set_mesh_clip' || !!v.clip, { message: 'set_mesh_clip requires clip', path: ['clip'] })
3598
+ .refine((v) => v.action !== 'set_nav_target' || !!v.navTarget, { message: 'set_nav_target requires navTarget', path: ['navTarget'] })
3599
+ .refine((v) => !['ground_height', 'canvas_to_ground', 'world_to_canvas'].includes(v.action) || !!v.point, { message: 'this action requires point', path: ['point'] })
3600
+ .refine((v) => v.action !== 'raycast' || (!!v.origin && !!v.direction), { message: 'raycast requires origin and direction', path: ['origin'] })
3601
+ .refine((v) => v.action !== 'dolly_camera' || v.multiplier !== undefined, { message: 'dolly_camera requires multiplier', path: ['multiplier'] })
3602
+ .refine((v) => v.action !== 'pan_camera' || (v.dx !== undefined && v.dy !== undefined), { message: 'pan_camera requires dx and dy', path: ['dx'] })
3603
+ .refine((v) => !['add_body', 'fire_projectile'].includes(v.action) || !!v.body, { message: 'this action requires body', path: ['body'] })
3604
+ .refine((v) => !['remove_body', 'impulse', 'set_velocity'].includes(v.action) || !!v.bodyId, { message: 'this action requires bodyId', path: ['bodyId'] })
3605
+ .refine((v) => !['impulse', 'set_velocity'].includes(v.action) || !!v.vector, { message: 'this action requires vector', path: ['vector'] })
3606
+ .refine((v) => v.action !== 'step_physics' || v.dt !== undefined, { message: 'step_physics requires dt', path: ['dt'] })
3607
+ .refine((v) => v.action !== 'line_of_sight' || (!!v.from && !!v.to), { message: 'line_of_sight requires from and to', path: ['from'] })
3608
+ .refine((v) => v.action !== 'set_mesh_instances' || (!!v.meshId && !!v.instances), { message: 'set_mesh_instances requires meshId and instances', path: ['meshId'] });
2864
3609
  // =============================================================================
2865
3610
  // ONE-SHOT IMAGE OPS (crop / chroma key — Track A agent parity, 2026-07-31)
2866
3611
  // =============================================================================
3612
+ /**
3613
+ * pinepaper_design_system — the design vocabulary, as data an agent can read.
3614
+ *
3615
+ * TWO DIFFERENT THINGS, deliberately not merged, because the upstream is
3616
+ * explicit that they are not the same:
3617
+ *
3618
+ * - A DESIGN SYSTEM is a real, licensed, versioned product — Material 3,
3619
+ * IBM Carbon, Polaris, Fluent 2, USWDS, GOV.UK. Its tokens are facts with a
3620
+ * licence attached, expressed in the W3C DTCG format ($value, $type,
3621
+ * $description). Where a motion token had to be filled in because the
3622
+ * published system does not specify one, it is marked
3623
+ * `pinepaper:authored` — so a caller can tell a vendor's curve from ours.
3624
+ * - An AESTHETIC STYLE is a look: Bauhaus, Art Deco, Swiss, vaporwave,
3625
+ * risograph. It carries no licence and no vendor. Eighteen of them can
3626
+ * COMPOSE a scene rather than only describe one.
3627
+ *
3628
+ * These run in this server, not in the browser. The generators are pure
3629
+ * functions over numbers, so the scene is computed here and arrives as items;
3630
+ * nothing about a design vocabulary needs the canvas to be open to decide it.
3631
+ */
3632
+ export const DesignSystemInputSchema = z.object({
3633
+ action: z.enum(['list_systems', 'get_system', 'list_easings', 'list_styles', 'compose'])
3634
+ .describe("'list_systems' every licensed design system with its vendor, licence and version · 'get_system' one system's full DTCG tokens · 'list_easings' every motion curve across all of them as named easings, with provenance · 'list_styles' the aesthetic styles and which can compose · 'compose' build a scene in a style."),
3635
+ systemId: z.string().optional().describe("get_system: which system, e.g. 'material_3', 'ibm_carbon', 'uswds'. Call list_systems for the ids."),
3636
+ tokenType: z.enum(['dimension', 'duration', 'cubicBezier', 'color', 'fontFamily', 'fontWeight', 'number', 'shadow', 'grid']).optional()
3637
+ .describe('get_system: return only tokens of this DTCG type.'),
3638
+ style: z.string().optional().describe("compose: the aesthetic style, e.g. 'bauhaus_geometric', 'art_deco_geometric', 'vaporwave_retro'. Call list_styles for the ones that can compose."),
3639
+ title: z.string().optional().describe('compose: the headline. Most styles are poster-shaped and build around it.'),
3640
+ subtitle: z.string().optional().describe('compose: the supporting line.'),
3641
+ body: z.string().optional().describe('compose: optional body copy, where the style has somewhere to put it.'),
3642
+ width: z.number().optional().describe('compose: canvas width (the style picks its own default otherwise).'),
3643
+ height: z.number().optional().describe('compose: canvas height.'),
3644
+ variant: z.string().optional().describe("compose: a style-specific variant where one exists, e.g. art deco's emerald colourway."),
3645
+ draw: z.boolean().optional().default(true)
3646
+ .describe('compose: draw the scene on the canvas (default). false returns the computed scene as DATA and draws nothing — the same distinction story\'s `distill` makes, and for the same reason: a caller should be able to read a composition before committing to it.'),
3647
+ authoredOnly: z.boolean().optional().describe('list_easings: return only the curves PinePaper authored to fill a gap, or only the ones a vendor actually publishes, rather than both mixed together.'),
3648
+ })
3649
+ .refine((v) => v.action !== 'get_system' || !!v.systemId, { message: 'get_system requires systemId — call list_systems for the ids', path: ['systemId'] })
3650
+ .refine((v) => v.action !== 'compose' || !!v.style, { message: 'compose requires style — call list_styles for the ones that can compose', path: ['style'] })
3651
+ .refine((v) => v.action !== 'compose' || !!v.title, { message: 'compose requires title', path: ['title'] });
3652
+ /**
3653
+ * pinepaper_stick — the vendored stick-figure kit.
3654
+ *
3655
+ * A different construction from pinepaper_character, which places a figure
3656
+ * from the design graph by concept. This is the stick kit: a rigged figure
3657
+ * posed, walking, travelling, holding a prop, with a garment and hair — plus
3658
+ * `set`, which builds the furniture it stands on and among.
3659
+ *
3660
+ * The geometry is vendored from mcp-cloud, so this is a three-repo artifact:
3661
+ * a change to the kit lands there first and is re-vendored into FxTool.
3662
+ */
3663
+ export const StickInputSchema = z.object({
3664
+ action: z.enum(['figure', 'set']).describe("'figure' a posed, walking or travelling stick figure · 'set' the floor, wall and objects it stands on and among."),
3665
+ id: z.string().optional().describe('Prefix for the created items.'),
3666
+ kind: z.string().optional().describe("figure: which figure. set: which piece of the scene kit ('floor', 'wall', a named object) — list-free, because the kit is vendored and grows there."),
3667
+ at: z.object({ x: z.number().optional(), y: z.number().optional() }).optional().describe('Where it goes.'),
3668
+ scale: z.number().optional().describe('Size multiplier.'),
3669
+ facing: z.enum(['left', 'right']).optional().describe('Which way the figure looks.'),
3670
+ pose: z.string().optional().describe("figure: a named pose from the kit's POSES."),
3671
+ poseAt: z.number().optional().describe('figure: the time, in seconds, at which that pose is struck.'),
3672
+ expression: z.string().optional().describe("figure: a single facial expression from the kit's EXPRESSIONS."),
3673
+ expressions: z.array(z.object({ at: z.number(), name: z.string() })).optional().describe('figure: expressions over time, each at a moment.'),
3674
+ walk: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional().describe('figure: a walk cycle — true for the default, or a config.'),
3675
+ travel: z.record(z.string(), z.unknown()).optional().describe('figure: move the figure across the scene while it walks, rather than walking in place.'),
3676
+ prop: z.string().optional().describe('figure: something held in hand.'),
3677
+ propSide: z.enum(['left', 'right']).optional().describe('figure: which hand holds it.'),
3678
+ garment: z.string().optional().describe('figure: clothing.'),
3679
+ trouser: z.string().optional().describe('figure: legwear.'),
3680
+ hair: z.string().optional().describe('figure: hair style.'),
3681
+ withHair: z.boolean().optional().describe('figure: draw hair at all.'),
3682
+ groundY: z.number().optional().describe('The y the figure stands on.'),
3683
+ surfaceY: z.number().optional().describe('The y of the surface an object sits on.'),
3684
+ durationSeconds: z.number().optional().describe('figure: length of the performance, so every track spans it.'),
3685
+ object: z.record(z.string(), z.unknown()).optional().describe('set: the object spec.'),
3686
+ items: z.array(z.record(z.string(), z.unknown())).optional().describe('set: several pieces at once.'),
3687
+ floor: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional().describe('set: draw a floor.'),
3688
+ wall: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional().describe('set: draw a wall.'),
3689
+ });
3690
+ /**
3691
+ * pinepaper_story — a piece of prose becomes a scene.
3692
+ *
3693
+ * `distill` reduces an article to its beats WITHOUT drawing anything, which is
3694
+ * the half worth having on its own: a caller can read what the distiller made
3695
+ * of the text, edit it, and only then assemble. `from_text` does both in one
3696
+ * call, `apply_spec` assembles a spec you already have, and `plan_book` lays
3697
+ * images out as pages.
3698
+ */
3699
+ export const StoryInputSchema = z.object({
3700
+ action: z.enum(['distill', 'from_text', 'apply_spec', 'plan_book'])
3701
+ .describe("'distill' text → story beats, drawing NOTHING · 'from_text' distill and assemble in one call · 'apply_spec' assemble a spec you already have · 'plan_book' lay images out as pages."),
3702
+ text: z.string().optional().describe('distill / from_text: the prose.'),
3703
+ spec: z.record(z.string(), z.unknown()).optional().describe('apply_spec: the story spec — usually one from `distill`, edited.'),
3704
+ images: z.array(z.union([z.string(), z.record(z.string(), z.unknown())])).optional().describe('plan_book: the images to lay out, as ids or specs.'),
3705
+ options: z.record(z.string(), z.unknown()).optional().describe('Assembly or layout options, passed through.'),
3706
+ })
3707
+ .refine((v) => !['distill', 'from_text'].includes(v.action) || !!v.text, { message: 'this action requires text', path: ['text'] })
3708
+ .refine((v) => v.action !== 'apply_spec' || !!v.spec, { message: 'apply_spec requires spec', path: ['spec'] })
3709
+ .refine((v) => v.action !== 'plan_book' || (Array.isArray(v.images) && v.images.length > 0), { message: 'plan_book requires images', path: ['images'] });
3710
+ /**
3711
+ * pinepaper_interchange — the formats other tools read and write.
3712
+ *
3713
+ * pinepaper_agent_export covers the PLATFORM formats: png, svg, mp4, webm,
3714
+ * gif, pdf, sized for Instagram or YouTube. These are the interchange ones —
3715
+ * a Lottie an app plays, a GLB a 3D tool opens, a BVH a rig imports, a PNG
3716
+ * sequence an editor ingests — and they had no tool at all.
3717
+ *
3718
+ * Import is here too, because a format you can only write is half a bridge.
3719
+ */
3720
+ export const InterchangeInputSchema = z.object({
3721
+ action: z.enum(['export_lottie', 'export_dotlottie', 'import_lottie', 'export_glb', 'export_bvh', 'export_png_sequence'])
3722
+ .describe("'export_lottie' (JSON) · 'export_dotlottie' (the zipped .lottie) · 'import_lottie' (a Lottie or .lottie back onto the canvas) · 'export_glb' (the perspective 3D objects — needs some to exist) · 'export_bvh' (a skeleton's motion — needs a rig) · 'export_png_sequence' (one file per frame)."),
3723
+ options: z.record(z.string(), z.unknown()).optional().describe('Format options, passed through to the exporter: frame range, dimensions, fps, naming.'),
3724
+ data: z.union([z.string(), z.record(z.string(), z.unknown())]).optional()
3725
+ .describe('import_lottie: the Lottie JSON — an object, a JSON string, or a URL.'),
3726
+ skeletonId: z.string().optional().describe('export_bvh: which skeleton to write. A BVH is one skeleton\'s motion, so this is not optional in practice.'),
3727
+ })
3728
+ .refine((v) => v.action !== 'import_lottie' || v.data !== undefined, { message: 'import_lottie requires data', path: ['data'] })
3729
+ .refine((v) => v.action !== 'export_bvh' || !!v.skeletonId, { message: 'export_bvh requires skeletonId — a BVH is one skeleton\'s motion', path: ['skeletonId'] });
3730
+ /**
3731
+ * pinepaper_sound — synthesis, and the drawing that is the same object.
3732
+ *
3733
+ * The engine carries a whole audio graph: tones, chords, named percussion and
3734
+ * SFX, plain-language sound descriptions, and — the part worth the tool — a
3735
+ * two-way bridge between a sound and a Paper path. `create` draws a sound as a
3736
+ * waveform path you can then edit; `timbre_from_path` reads any drawn path
3737
+ * back as a timbre. A curve someone drew becomes the harmonic content of a
3738
+ * note.
3739
+ *
3740
+ * None of it was reachable. `pinepaper_audio_beats` analyses audio that
3741
+ * already exists; this makes audio.
3742
+ */
3743
+ export const SoundInputSchema = z.object({
3744
+ action: z.enum([
3745
+ 'list_instruments', 'list_percussion', 'list_sfx',
3746
+ 'play_tone', 'play_chord', 'chord_frequencies', 'play_percussion', 'play_sfx',
3747
+ 'play_spec', 'from_text', 'play_from_text',
3748
+ 'create', 'timbre_from_path', 'set_placement', 'remove', 'stop_all',
3749
+ 'define_instrument', 'define_percussion', 'define_sfx', 'render_soundtrack',
3750
+ ]).describe("Catalogues: 'list_instruments' · 'list_percussion' · 'list_sfx'. Play: 'play_tone' · 'play_chord' · 'play_percussion' · 'play_sfx' · 'play_spec' · 'play_from_text'. Read without playing: 'chord_frequencies' · 'from_text' (a plain-language description resolved to a spec) · 'timbre_from_path'. Canvas: 'create' (a sound drawn AS a waveform path) · 'set_placement' · 'remove' · 'stop_all'. Define: 'define_instrument' · 'define_percussion' · 'define_sfx' — the six built-in instruments are a starting set, not a claim that music contains six; anything missing can be defined here and is then first-class, listed by the list_* actions and playable by name. Render: 'render_soundtrack' — mix every placed sound to a WAV file offline, with no Web Audio and no playback."),
3751
+ note: z.string().optional().describe("play_tone: scientific pitch, e.g. 'A4' or 'C#3'."),
3752
+ root: z.string().optional().describe("play_chord / chord_frequencies: the root note, e.g. 'C4'."),
3753
+ chord: z.string().optional().default('major').describe("play_chord / chord_frequencies: the chord kind, e.g. 'major', 'minor', 'maj7', 'dim'. Call the engine rather than guessing at exotic names."),
3754
+ name: z.string().optional().describe("play_percussion / play_sfx: the named drum or effect — list them first (SFX include beep, pop, wind, whoosh, zap). define_*: the name to register under. It is NORMALISED to [a-z][a-z0-9_-]{0,31}, so 'Rhodes' registers as 'rhodes' — the response returns the canonical name the engine chose, and that is the name to play."),
3755
+ partials: z.array(z.object({
3756
+ h: z.number().positive().describe('Harmonic number — 1 is the fundamental, 2 the octave above.'),
3757
+ amp: z.number().min(0).describe('Relative amplitude of this partial.'),
3758
+ })).optional().describe("define_*: the harmonic table that IS the timbre. Required for define_instrument — a melodic instrument with no partials renders pure silence, and the engine refuses rather than handing back a note nobody can hear. Percussion and SFX may use noise instead."),
3759
+ envelope: z.object({
3760
+ attack: z.number().min(0).optional(),
3761
+ decay: z.number().min(0).optional(),
3762
+ sustain: z.number().min(0).max(1).optional(),
3763
+ release: z.number().min(0).optional(),
3764
+ }).optional().describe('define_*: ADSR shape, in seconds except sustain which is a level (0-1).'),
3765
+ gain: z.number().min(0).max(1).optional().describe('define_*: output level. Defaults differ by kind — 0.8 instrument, 0.85 percussion, 0.65 sfx.'),
3766
+ aliases: z.array(z.string()).optional().describe("define_*: extra names reaching the same sound, so 'rhodes' can also answer to 'electric piano'. An alias NEVER shadows a real catalogue entry — the table is consulted first — so aliasing an existing name is silently ignored rather than overriding it."),
3767
+ noise: z.number().min(0).max(1).optional().describe('define_percussion / define_sfx: noise content. A hat, a clap, wind and whoosh are pure noise, so these two kinds accept a partial table OR noise > 0; with neither the engine refuses, because the result is silent.'),
3768
+ noiseFreq: z.number().positive().optional().describe('define_percussion / define_sfx: centre frequency of the noise band, in Hz.'),
3769
+ hz: z.number().positive().optional().describe('define_percussion / define_sfx: base frequency in Hz.'),
3770
+ pitch: z.object({
3771
+ from: z.number().positive().describe('Starting frequency in Hz.'),
3772
+ tau: z.number().positive().max(5).describe('Glide time constant in seconds (max 5).'),
3773
+ }).optional().describe("define_percussion / define_sfx: the pitch glide, which is what makes a kick a kick — the engine's own note is that the glide IS the drum. Omit it and a defined kick comes out a beep."),
3774
+ sampleRate: z.number().int().positive().optional().describe('render_soundtrack: samples per second (default 48000).'),
3775
+ bitDepth: z.union([z.literal(16), z.literal(32)]).optional().describe('render_soundtrack: 16 (default) or 32-bit float.'),
3776
+ text: z.string().optional().describe("from_text / play_from_text: a plain-language description, e.g. 'a soft warm bell on A4'. 'from_text' resolves it to a spec WITHOUT playing, so a caller can inspect or edit before committing."),
3777
+ spec: z.record(z.string(), z.unknown()).optional().describe('play_spec / create: the sound spec — partials, envelope, duration. Usually one you got from from_text or a catalogue entry rather than wrote by hand.'),
3778
+ options: z.record(z.string(), z.unknown()).optional().describe('Playback options for the play_* actions: duration, gain, pan, and the rest the audio graph accepts.'),
3779
+ itemId: z.string().optional().describe('timbre_from_path: the path whose SHAPE becomes the harmonic content. set_placement / remove: the sound item.'),
3780
+ samples: z.number().int().positive().optional().describe('timbre_from_path: how many points to sample along the path (default 256). More is a finer timbre and a slower read.'),
3781
+ placement: z.record(z.string(), z.unknown()).optional().describe('set_placement: where and when the sound sits on the timeline.'),
3782
+ visual: z.object({
3783
+ width: z.number().optional().describe('Waveform path width in canvas units (default 220).'),
3784
+ height: z.number().optional().describe('Waveform path height in canvas units (default 64).'),
3785
+ position: z.object({ x: z.number(), y: z.number() }).optional(),
3786
+ color: z.string().optional(),
3787
+ }).optional().describe('create: how the waveform path is drawn. The sound IS this item — edit the path and the timbre changes with it.'),
3788
+ })
3789
+ .refine((v) => v.action !== 'play_tone' || !!v.note, { message: 'play_tone requires note', path: ['note'] })
3790
+ .refine((v) => !['play_chord', 'chord_frequencies'].includes(v.action) || !!v.root, { message: 'this action requires root', path: ['root'] })
3791
+ .refine((v) => !['play_percussion', 'play_sfx'].includes(v.action) || !!v.name, { message: 'this action requires name — list the catalogue first', path: ['name'] })
3792
+ .refine((v) => !['from_text', 'play_from_text'].includes(v.action) || !!v.text, { message: 'this action requires text', path: ['text'] })
3793
+ .refine((v) => v.action !== 'play_spec' || !!v.spec, { message: 'play_spec requires spec', path: ['spec'] })
3794
+ .refine((v) => !['timbre_from_path', 'set_placement', 'remove'].includes(v.action) || !!v.itemId, { message: 'this action requires itemId', path: ['itemId'] })
3795
+ .refine((v) => v.action !== 'set_placement' || !!v.placement, { message: 'set_placement requires placement', path: ['placement'] })
3796
+ .refine((v) => !['define_instrument', 'define_percussion', 'define_sfx'].includes(v.action) || !!v.name, { message: 'defining a sound requires name', path: ['name'] })
3797
+ // The engine's own rule, stated here so the refusal arrives before the round
3798
+ // trip: a melodic instrument needs partials, while percussion and SFX may be
3799
+ // pure noise. Mirrored rather than tightened — tightening it would refuse a
3800
+ // hat, which is exactly the sound that has no partials.
3801
+ .refine((v) => v.action !== 'define_instrument' || (v.partials?.length ?? 0) > 0, { message: 'define_instrument requires at least one partial — an instrument with none renders silence', path: ['partials'] })
3802
+ .refine((v) => !['define_percussion', 'define_sfx'].includes(v.action)
3803
+ || (v.partials?.length ?? 0) > 0 || (v.noise ?? 0) > 0, { message: 'this action needs a partial table or noise > 0 — with neither the sound is silent', path: ['noise'] })
3804
+ // Bound by the scene, like every other timed export.
3805
+ .refine((v) => v.action !== 'render_soundtrack' || (v.options?.duration ?? 1) <= VIDEO_MAX_DURATION_S, { message: `render_soundtrack duration is capped at ${VIDEO_MAX_DURATION_S}s, the same ceiling as video`, path: ['options'] });
3806
+ /**
3807
+ * pinepaper_motion — the generators' Animation knob, pointed at anything.
3808
+ *
3809
+ * The same engine every generator's own animation runs on, reachable for any
3810
+ * group or list of items. Two kinds: a GROUP motion (drift, sway, rotate,
3811
+ * pulse, wave, bounce) moves the target as one; a FIELD motion (ripple,
3812
+ * breathe, undulate) sweeps a crest through the children from an origin, with
3813
+ * a chosen waveform. The field kind is the one nothing else here can do.
3814
+ *
3815
+ * `list` first. The catalogue is the engine's own and names every motion,
3816
+ * waveform and origin it will accept — the alternative is guessing at an enum
3817
+ * that moves.
3818
+ */
3819
+ export const MotionInputSchema = z.object({
3820
+ action: z.enum(['list', 'apply']).describe("'list' every motion, waveform and origin the engine offers · 'apply' one to a target."),
3821
+ itemId: z.string().optional().describe('apply: a group or single item to move.'),
3822
+ itemIds: z.array(z.string()).optional().describe('apply: several items, wrapped in a new group that becomes the motion host. The members keep their own identity and ids.'),
3823
+ motion: z.string().optional().describe("apply: the motion name — a GROUP motion (drift, sway, rotate, pulse, wave, bounce) moves the target as one; a FIELD motion (ripple, breathe, undulate) sweeps a crest through its children. Call 'list' for the engine's own set rather than guessing."),
3824
+ speed: z.number().optional().describe('apply: cycles per second, roughly. Higher is faster.'),
3825
+ intensity: z.number().optional().describe('apply: amplitude. 0.15 is the usual default elsewhere in this surface.'),
3826
+ waveform: z.enum(['sine', 'triangle', 'square', 'sawtooth', 'spike']).optional()
3827
+ .describe("apply: the shape of one cycle. 'spike' is a narrow bump — it was called 'pulse' until the engine noticed one word was naming both a waveform and an animation; the old spelling is still accepted on the way in, but 'spike' is the name."),
3828
+ origin: z.enum(['center', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight', 'left', 'right', 'top', 'bottom', 'random', 'roam']).optional()
3829
+ .describe("apply, FIELD motions only: where the crest starts and sweeps from. 'random' gives every element its own phase (a twinkle rather than a wave); 'roam' hops between the corners and the centre."),
3830
+ seed: z.number().optional().describe('apply: makes a random origin reproducible — the same seed lays out the same phases every run.'),
3831
+ })
3832
+ .refine((v) => v.action !== 'apply' || !!v.itemId || (Array.isArray(v.itemIds) && v.itemIds.length > 0), { message: 'apply requires itemId or itemIds', path: ['itemId'] })
3833
+ .refine((v) => v.action !== 'apply' || !!v.motion, { message: "apply requires motion — call action 'list' for the names the engine accepts", path: ['motion'] });
3834
+ /**
3835
+ * pinepaper_path — the destructive path operations Paper.js has always had and
3836
+ * nothing here could reach: booleans, simplify, outline-stroke, open/close,
3837
+ * repeat patterns, plus read-back geometry and a lock.
3838
+ *
3839
+ * Every one of these REPLACES or ADDS items rather than restyling them, which
3840
+ * is why they live together and away from `pinepaper_modify_item`: a boolean
3841
+ * consumes its operands, and an outlined stroke is a new filled shape where a
3842
+ * stroked line used to be. The engine refuses each by name — "a boolean needs
3843
+ * at least two paths", "that path has no stroke to outline" — and those
3844
+ * refusals are passed through rather than reworded.
3845
+ */
3846
+ export const PathOpInputSchema = z.object({
3847
+ action: z.enum(['boolean', 'simplify', 'outline_stroke', 'toggle_closed', 'pattern', 'get_geometry', 'set_locked', 'unlock_all'])
3848
+ .describe("'boolean' (unite|subtract|intersect|exclude|divide, consumes its operands) · 'simplify' (fewer segments, same shape) · 'outline_stroke' (a stroked line becomes a filled shape) · 'toggle_closed' · 'pattern' (repeat an item: concentric|radial|grid|extrude) · 'get_geometry' (read segments back) · 'set_locked' / 'unlock_all'"),
3849
+ itemId: z.string().optional().describe('The item to operate on — every action except boolean and unlock_all.'),
3850
+ itemIds: z.array(z.string()).optional().describe('boolean: at least two paths. The FIRST is the base; the rest are applied to it in order.'),
3851
+ op: z.enum(['unite', 'subtract', 'intersect', 'exclude', 'divide']).optional().default('unite')
3852
+ .describe("boolean: 'unite' merges · 'subtract' removes the later shapes from the first · 'intersect' keeps the overlap · 'exclude' keeps everything but the overlap · 'divide' splits into every region."),
3853
+ tolerance: z.number().positive().optional()
3854
+ .describe('simplify: how far the new curve may stray, in canvas units (default 2.5 — higher is fewer points and a looser shape). outline_stroke: flattening tolerance (defaults to an eighth of the stroke radius).'),
3855
+ kind: z.enum(['concentric', 'radial', 'grid', 'extrude']).optional().default('radial')
3856
+ .describe("pattern: 'concentric' rings outward · 'radial' around a circle · 'grid' rows and columns · 'extrude' a depth-wise stack."),
3857
+ pattern: z.object({
3858
+ count: z.number().optional().describe('concentric / radial / extrude: how many copies.'),
3859
+ radius: z.number().optional().describe('radial / concentric: distance from the original.'),
3860
+ angle: z.number().optional().describe('radial: total sweep in DEGREES (360 is a full ring).'),
3861
+ phase: z.number().optional().describe('radial: where the first copy starts, in DEGREES.'),
3862
+ rows: z.number().optional().describe('grid: row count.'),
3863
+ cols: z.number().optional().describe('grid: column count.'),
3864
+ gapX: z.number().optional().describe('grid: horizontal spacing in canvas units.'),
3865
+ gapY: z.number().optional().describe('grid: vertical spacing in canvas units.'),
3866
+ step: z.number().optional().describe('extrude / concentric: spacing between successive copies.'),
3867
+ depth: z.number().optional().describe('extrude: total depth the stack spans.'),
3868
+ rotateItems: z.boolean().optional().describe('radial: turn each copy to face along the ring instead of keeping its original angle.'),
3869
+ shade: z.boolean().optional().describe('extrude: darken copies with depth, so the stack reads as receding.'),
3870
+ shadeStrength: z.number().optional().describe('extrude: how strong that darkening is.'),
3871
+ }).optional().describe('pattern options. Copies are capped — past a few hundred this stops being a drawing aid and becomes a generator, which is a different tool.'),
3872
+ locked: z.boolean().optional().default(true).describe('set_locked: true locks (also clearing selectable and draggable, so it cannot be grabbed), false unlocks.'),
3873
+ })
3874
+ .refine((v) => v.action !== 'boolean' || (Array.isArray(v.itemIds) && v.itemIds.length >= 2), { message: 'boolean requires itemIds with at least two paths', path: ['itemIds'] })
3875
+ .refine((v) => ['boolean', 'unlock_all'].includes(v.action) || !!v.itemId, { message: 'this action requires itemId', path: ['itemId'] });
2867
3876
  export const CropImageInputSchema = z.object({
2868
3877
  itemId: z.string().describe('Registry id of the raster item (or a group containing one).'),
2869
3878
  rect: z.object({
@@ -2892,7 +3901,71 @@ export const RiggingInputSchema = z.object({
2892
3901
  'create_skeleton', 'add_bone', 'attach_item', 'create_ik_chain',
2893
3902
  'add_pose_keyframe', 'set_target_path', 'save_pose', 'save_shape_key',
2894
3903
  'import_bvh', 'retarget_bvh', 'import_spine',
3904
+ // ── Pose motion. The engine has had these for releases; nothing could
3905
+ // reach them, which is not the same as their not existing.
3906
+ 'list_skeletons', 'list_bones', 'list_poses', 'load_pose', 'interpolate_poses',
3907
+ 'list_pose_libraries', 'load_pose_library',
3908
+ 'play_pose_sequence', 'stop_pose_sequence', 'stitch_poses',
3909
+ 'apply_pose_transition',
3910
+ 'auto_walk', 'auto_breath', 'auto_idle', 'auto_jump',
3911
+ 'move_root', 'stop_root_track',
3912
+ 'add_secondary_motion', 'skin_path', 'bake_animation',
3913
+ 'list_shape_keys', 'load_shape_key',
2895
3914
  ]).describe('Rigging operation'),
3915
+ // ── Pose motion ────────────────────────────────────────────────────────
3916
+ poseId: z.string().optional().describe('Saved pose id — load_pose.'),
3917
+ poseIdA: z.string().optional().describe('First pose — interpolate_poses.'),
3918
+ poseIdB: z.string().optional().describe('Second pose — interpolate_poses.'),
3919
+ t: z.number().min(0).max(1).optional().describe('Blend 0=A … 1=B — interpolate_poses.'),
3920
+ /**
3921
+ * A pose sequence: keys over normalized time, each naming a SAVED pose or an
3922
+ * inline { boneId: angleDeg } map. The saved-pose form is the point — an
3923
+ * animation that is literally a graph over the poses in the Poses panel.
3924
+ */
3925
+ sequence: z.array(z.object({
3926
+ t: z.number().describe('Time (normalized within the clip unless duration is given).'),
3927
+ pose: z.string().optional().describe('Saved pose id.'),
3928
+ angles: z.record(z.string(), z.number()).optional().describe('Inline { boneId: angleDeg }.'),
3929
+ ease: z.string().optional(),
3930
+ })).optional().describe('Pose keys — play_pose_sequence.'),
3931
+ /**
3932
+ * Clips to STITCH into one continuous performance. Each clip is a sequence
3933
+ * plus how it joins: `repeat` plays it N times, `loop` marks it cyclic (only
3934
+ * a cyclic clip can be phase-matched), `blend` is the seam overlap.
3935
+ */
3936
+ clips: z.array(z.object({
3937
+ keys: z.array(z.object({
3938
+ t: z.number().optional(),
3939
+ pose: z.string().optional(),
3940
+ angles: z.record(z.string(), z.number()).optional(),
3941
+ ease: z.string().optional(),
3942
+ })).optional(),
3943
+ poses: z.array(z.object({
3944
+ pose: z.string().optional(),
3945
+ angles: z.record(z.string(), z.number()).optional(),
3946
+ })).optional().describe('Bare list — spread evenly across the clip.'),
3947
+ duration: z.number().positive().optional(),
3948
+ repeat: z.number().int().min(1).max(200).optional(),
3949
+ loop: z.boolean().optional().describe('Cyclic: can be entered at any phase.'),
3950
+ blend: z.number().min(0).optional().describe('Override the seam overlap into this clip.'),
3951
+ matchPhase: z.boolean().optional(),
3952
+ })).optional().describe('Clips in order — stitch_poses.'),
3953
+ blend: z.number().min(0).optional().describe('Seam overlap in seconds (default 0.25) — stitch_poses.'),
3954
+ blendSteps: z.number().int().min(2).max(64).optional().describe('Keys emitted across a seam (default 6) — stitch_poses.'),
3955
+ matchPhase: z.boolean().optional().describe('Enter a cyclic clip at the phase closest to where the last one ended (default true) — stitch_poses.'),
3956
+ plan: z.boolean().optional().describe('Return the seams WITHOUT installing the result — stitch_poses. Read `residual` per seam: it is the mismatch the blend is hiding, in degrees.'),
3957
+ libraryName: z.string().optional().describe("Stock pose set — load_pose_library. The LIBRARIES are rig shapes: humanoid, quadruped, bird, fish. Loading one saves its poses (idle, walk_00…walk_07, walk_contact_L, run_*, …) against your bones. The walk/run/flap SEQUENCES are transitions, not libraries — play those with apply_pose_transition once the library is loaded. list_pose_libraries returns both lists."),
3958
+ boneMap: z.record(z.string(), z.string()).optional().describe('Bone NAME → bone id — load_pose_library. Omit and it is built from list_bones, which is what you want unless the rig uses non-standard names.'),
3959
+ transitionName: z.string().optional().describe('Named pose transition — apply_pose_transition.'),
3960
+ poseIdMap: z.record(z.string(), z.string()).optional().describe('Transition pose name → saved pose id — apply_pose_transition.'),
3961
+ boneNames: z.array(z.string()).optional().describe('Ordered bone names for a spring chain — add_secondary_motion.'),
3962
+ keyframes: z.array(z.object({
3963
+ t: z.number().describe('SCENE SECONDS, not a 0..1 fraction.'),
3964
+ x: z.number(), y: z.number(), ease: z.string().optional(),
3965
+ })).optional().describe('World root positions over SCENE TIME — move_root. This is what makes a figure travel instead of walking on the spot. `t` is in seconds on the same clock as the pose sequence, so match the last keyframe to the performance duration or the figure arrives early and stands there. Pass options.loop with options.period to cycle it.'),
3966
+ shapeKeyId: z.string().optional().describe('Saved shape key id — load_shape_key.'),
3967
+ weight: z.number().optional().describe('Shape key weight 0..1 (default 1) — load_shape_key.'),
3968
+ options: z.record(z.string(), z.unknown()).optional().describe('Pass-through options for auto_* / bake_animation / move_root / skin_path / play_pose_sequence.'),
2896
3969
  // ── Mocap / rig import (import_bvh, retarget_bvh, import_spine) ──
2897
3970
  // The file CONTENTS travel in the call (MCP has no filesystem): .bvh text for
2898
3971
  // BVH, the exported .json for Spine. import_bvh builds a NEW stick-figure rig