Publish Forma Engine 0.3.0 source with documentation and CI

This commit is contained in:
emil28092005
2026-09-09 15:49:08 +03:00
commit e52bc0e33b
70 changed files with 19610 additions and 0 deletions
+647
View File
@@ -0,0 +1,647 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod/v4";
import { ProjectStore } from "../engine/store.ts";
import { entity, validateGeometry } from "../engine/schema.ts";
import { defaultProject } from "../engine/templates.ts";
import { arena, character, extrude, lathe } from "../engine/geometry.ts";
export interface EngineService {
builds?: import("./builds.ts").BuildManager;
store: ProjectStore;
status: () => any;
save: () => Promise<any>;
exportWeb: () => Promise<any>;
importModel: (a: any) => Promise<any>;
runtime: (action: string, args?: any) => Promise<any>;
}
export const commandReference = {
transactions:
"1–1000 commands apply atomically. Read project_read first. Mutation expectedRevision must match current revision. requestId deduplicates successful retried transactions. On REVISION_CONFLICT reread project before editing.",
coordinates:
"Y up, meters, radians. Entity transforms local to parent. Reparent preserves local coordinates unless you provide transform. Kinematic move uses world displacement; other move uses local.",
commands: {
"project.rename": "{name}",
"project.settings":
'{background:"#dedbd2",ambient:0.85,shadows:true,renderScale:1}',
"scene.create": "{id?,name}",
"scene.activate": "{id}",
"scene.rename": "{sceneId,name}",
"node.create":
"{name,id?,position?,parentId?,components?,sceneId?} OR {entity:{id,name,parentId:null,enabled:true,transform:{position:[0,0,0],rotation:[0,0,0],scale:[1,1,1]},components:{}}}",
"node.patch":
"{id,patch:{name?,enabled?,transform?,components?},sceneId?}; deep merge, arrays replace, id/parentId immutable here",
"node.reparent": "{id,parentId:null|string,transform?,sceneId?}",
"node.delete": "{id,sceneId?}; subtree",
"node.duplicate": "{id,sceneId?}; subtree and internal references",
"component.set": "{id,type,value,sceneId?}; replaces component",
"component.remove": "{id,type,sceneId?}",
"asset.upsert":
'{asset:{id,name,kind:"model"|"geometry"|"prefab",uri?,geometry?,entities?,metadata?}}',
"asset.delete": "{id}; fails if referenced",
"script.upsert":
'{script:{id,name,source,fields:{speed:{type:"number",default:5,label:"Speed",min:0,max:30}}}}',
"prefab.create": "{id,name?,sceneId?}",
"prefab.instantiate": "{assetId,position?,sceneId?}",
},
components: {
mesh: {
type: "box | sphere | cylinder | icosphere | torus | model | geometry | custom",
size: [1, 1, 1],
assetId: "for model/geometry types",
geometry: "{positions,indices,normals?,uvs?} for custom type",
},
material: {
color: "#91a697",
roughness: 0.8,
metallic: 0,
emissive: 0,
override: false,
},
collider: {
shape: "box | ball | capsule",
size: [1, 1, 1],
radius: 0.32,
height: 1.8,
offset: [0, 0.9, 0],
sensor: false,
enabled: true,
},
rigidbody: {
type: "fixed | dynamic | kinematic",
mass: 1,
restitution: 0.1,
},
camera: { mode: "follow | firstPerson", targetId: "subject", offset: [0, 13, -10], fov: 0.72, yaw: 0, pitch: 0 },
character: { gravity: 24, autostep: 0.25, requires: "kinematic rigidbody + capsule collider" },
sign: { text: "Text in the scene", color: "#d7f34b", width: 5 },
light: { color: "#fff1da", intensity: 2 },
animator: {
idle: "Idle",
run: "Run",
attack: "Attack",
death: "Death",
speed: 1,
},
script: {
scriptId: "script_rotate",
params: { speed: 1 },
},
data: { customValue: 1, label: "Application-defined properties" },
},
};
export const scriptReference = {
source:
"JavaScript expression returning {start(api), update(api,dt)}. No imports or TypeScript. Worker watchdog 1500ms. Imported project scripts are TRUSTED code; Worker is responsiveness isolation, not a security sandbox.",
api: {
state: "Persistent per-instance mutable data during one play run",
params: "Field defaults + component.script.params",
input: "{x,z,attack,pointer,aim,jump,dash,sprint,jumpPressed,dashPressed,resetPressed,yaw,pitch}; yaw=0 faces -Z in first person",
get: "api.get(id?) -> clone of entity or null",
entities: "api.entities() -> clones of entity states",
position: "api.position(id?) -> local coordinates",
move: "api.move([dx,dy,dz]); real collisions for kinematic body",
physics: "api.physics(id?) -> {grounded,velocity:{x,y,z},contacts:[{entityId,normal:[x,y,z]}]}",
velocity: "api.velocity({x?,y?,z?,gravityScale?}); persistent m/s, requires character component; gravity runs at 60 Hz",
teleport: "api.teleport([x,y,z],yaw?); clears velocity and contacts for character respawn",
emit: "api.emit(name,data?); delivers a presentation event to runtime callbacks",
rotate: "api.rotate(yRadians)",
patch:
"api.patch(id,patch); updates state/transform/enabled. Structural mesh/collider edits take effect next Play.",
animate: "api.animate(clipOrState,loop=true); use false for a one-shot animation",
effect: 'api.effect("swing"|"hit",id?)',
spawn:
"api.spawn(prefabAssetId,position); behaviors start on spawned instances",
destroy: "api.destroy(id?); disables entity+collider",
scene: "api.scene(sceneId); starts another scene",
log: "api.log(text)",
},
example:
"({ update(api, dt) { const n = api.get(); api.rotate(n.transform.rotation[1] + api.params.speed * dt); } })",
};
const json = z.record(z.string(), z.unknown()),
vector = z.tuple([z.number(), z.number(), z.number()]),
revision = {
expectedRevision: z.number().int().nonnegative(),
requestId: z.string().max(100).optional(),
};
export function createMcp(s: EngineService) {
const server = new McpServer(
{ name: "forma-engine", version: "0.3.0" },
{
instructions:
"Read project_read and forma://reference/commands first. Use expectedRevision, atomic transactions and verify runtime evidence. This is a real local engine. Do not claim testing without observed runtime tool results.",
},
);
function tool(
name: string,
description: string,
inputSchema: any,
fn: (a: any) => any,
readOnlyHint = false,
) {
server.registerTool(
name,
{
description,
inputSchema,
annotations: {
readOnlyHint,
destructiveHint: !readOnlyHint,
idempotentHint: readOnlyHint,
openWorldHint: false,
},
},
async (a: any): Promise<CallToolResult> => {
try {
const r = await fn(a);
if (
typeof r?.image === "string" &&
r.image.startsWith("data:image/png;base64,")
) {
const { image, ...meta } = r;
return {
content: [
{
type: "image",
mimeType: "image/png",
data: image.split(",")[1],
},
{ type: "text", text: JSON.stringify(meta) },
],
structuredContent: meta,
};
}
return {
content: [{ type: "text", text: JSON.stringify(r) }],
structuredContent: r,
};
} catch (e) {
return {
isError: true,
content: [{ type: "text", text: String(e) }],
};
}
},
);
}
const builds = () => {
if (!s.builds) throw Error("BUILD_SERVICE_UNAVAILABLE");
return s.builds;
};
tool(
"build_targets",
"Check local app build tools and Android release signing availability.",
{},
() => builds().capabilities(),
true,
);
tool(
"build_start",
"Build an immutable project snapshot into Linux x64 AppImage, Windows x64 portable EXE or Android APK. Returns a job; poll build_status until terminal. First-time tool downloads need network. Never claim device testing from package success.",
{
expectedRevision: z.number().int().nonnegative(),
options: z.object({
target: z.enum(["linux", "windows", "android"]),
name: z.string().optional(),
appId: z.string().optional(),
version: z.string().optional(),
versionCode: z.number().int().optional(),
mode: z.enum(["debug", "release"]).optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
fullscreen: z.boolean().optional(),
orientation: z.enum(["landscape", "portrait", "sensor"]).optional(),
}),
},
(a: any) => builds().start(s.store.project, a.options, a.expectedRevision),
);
tool(
"build_status",
"Read build status, bounded log and artifact URLs with SHA-256.",
{ id: z.string() },
(a: any) => builds().get(a.id),
true,
);
tool(
"build_list",
"List recent local build jobs.",
{},
() => ({ jobs: builds().list() }),
true,
);
tool(
"build_cancel",
"Cancel a queued or running build and stop its child processes.",
{ id: z.string() },
(a: any) => builds().cancel(a.id),
);
const tx = (a: any, commands: any[], label: string) =>
s.store.transaction({
commands,
expectedRevision: a.expectedRevision,
requestId: a.requestId,
label,
source: "mcp",
});
tool(
"project_read",
"Project revision, scenes, asset summaries, scripts, settings and editor status. Binary model data omitted.",
{},
() => {
const p = s.store.project;
return {
...s.status(),
id: p.id,
name: p.name,
revision: p.revision,
activeSceneId: p.activeSceneId,
scenes: p.scenes.map((s) => ({
id: s.id,
name: s.name,
objects: s.entities.length,
})),
assets: p.assets.map(({ uri, geometry, entities, ...rest }) => rest),
scripts: p.scripts,
settings: p.settings,
};
},
true,
);
tool(
"project_new",
"Replace active project with an empty scene. Undoable. Save your current project first.",
{
...revision,
name: z.string().max(200),
template: z.literal("empty").default("empty"),
},
(a) => {
const p = defaultProject();
p.name = a.name;
return tx(
a,
[{ op: "project.replace", args: { project: p } }],
"Новый проект",
);
},
);
tool(
"scene_read",
"Read entity hierarchy and components. Geometry arrays omitted unless requested.",
{
sceneId: z.string().optional(),
includeGeometry: z.boolean().default(false),
},
(a) => {
const scene = s.store.project.scenes.find(
(p) => p.id === (a.sceneId || s.store.project.activeSceneId),
);
if (!scene) throw Error("Scene not found");
const result = structuredClone(scene);
if (!a.includeGeometry)
for (const n of result.entities) {
const g = n.components.mesh?.geometry;
if (g)
n.components.mesh.geometry = {
vertexCount: g.positions.length / 3,
triangles: g.indices.length / 3,
};
}
return { revision: s.store.project.revision, scene: result };
},
true,
);
tool(
"scene_create",
"Create and activate a scene.",
{ ...revision, name: z.string(), id: z.string().optional() },
(a) =>
tx(
a,
[
{
op: "scene.create",
args: { name: a.name, ...(a.id ? { id: a.id } : {}) },
},
],
"Создать сцену",
),
);
tool(
"commands_apply",
"Apply one atomic command batch. Read forma://reference/commands for schemas.",
{
...revision,
label: z.string().max(200),
commands: z
.array(z.object({ op: z.string(), args: json }))
.min(1)
.max(1000),
},
(a) => tx(a, a.commands, a.label),
);
tool(
"node_create",
"Create a 3D entity with arbitrary components.",
{
...revision,
name: z.string(),
id: z.string().optional(),
parentId: z.string().optional(),
position: vector.default([0, 0, 0]),
components: json.default({}),
},
(a) =>
tx(
a,
[
{
op: "node.create",
args: {
name: a.name,
position: a.position,
components: a.components,
...(a.id ? { id: a.id } : {}),
...(a.parentId ? { parentId: a.parentId } : {}),
},
},
],
"Создать " + a.name,
),
);
tool(
"node_update",
"Deep merge object properties. Use node.reparent command to change hierarchy.",
{ ...revision, id: z.string(), patch: json },
(a) =>
tx(
a,
[{ op: "node.patch", args: { id: a.id, patch: a.patch } }],
"Изменить объект",
),
);
for (const op of ["delete", "duplicate"])
tool(
"node_" + op,
op + " object subtree.",
{ ...revision, id: z.string() },
(a) => tx(a, [{ op: "node." + op, args: { id: a.id } }], op),
);
tool(
"model_generate",
"Generate editable geometry WITHOUT Blender. arena: level; character: static stylized figure; extrude: polygon [x,z]+depth; lathe: profile [radius,y]+segments.",
{
...revision,
kind: z.enum(["arena", "character", "extrude", "lathe"]),
name: z.string().optional(),
width: z.number().min(8).max(80).optional(),
depth: z.number().min(0.05).max(80).optional(),
height: z.number().min(0.1).max(20).optional(),
seed: z.number().int().optional(),
obstacles: z.number().int().min(0).max(80).optional(),
segments: z.number().int().min(3).max(128).optional(),
color: z
.string()
.regex(/^#[\da-fA-F]{6}$/)
.optional(),
profile: z
.array(z.tuple([z.number(), z.number()]))
.max(256)
.optional(),
},
(a) => {
let nodes;
if (a.kind === "arena") nodes = arena(a);
else if (a.kind === "character") nodes = character(a);
else {
if (!a.profile) throw Error("profile required");
const geometry =
a.kind === "extrude"
? extrude(a.profile, a.depth || 1)
: lathe(a.profile, a.segments || 24);
nodes = [
entity(a.name || a.kind, {
mesh: { type: "custom", geometry },
material: { color: a.color || "#91a697", roughness: 0.8 },
}),
];
}
if (a.name) nodes[0].name = a.name;
return tx(
a,
nodes.map((n) => ({ op: "node.create", args: { entity: n } })),
"Генерация " + a.kind,
);
},
);
tool(
"mesh_create",
"Create custom triangle mesh directly from vertex arrays.",
{
...revision,
name: z.string(),
positions: z.array(z.number()).max(900000),
indices: z.array(z.number().int()).max(1800000),
normals: z.array(z.number()).optional(),
uvs: z.array(z.number()).optional(),
color: z.string().default("#91a697"),
},
(a) => {
const geometry = {
positions: a.positions,
indices: a.indices,
...(a.normals ? { normals: a.normals } : {}),
...(a.uvs ? { uvs: a.uvs } : {}),
};
validateGeometry(geometry);
return tx(
a,
[
{
op: "node.create",
args: {
entity: entity(a.name, {
mesh: { type: "custom", geometry },
material: { color: a.color, roughness: 0.8 },
}),
},
},
],
"Создать сетку",
);
},
);
tool(
"asset_import_glb",
"Import GLB or embedded glTF using base64 bytes OR a path inside the project folder. External URLs are not fetched.",
{
...revision,
name: z.string().regex(/\.(glb|gltf)$/i),
base64: z.string().max(36_000_000).optional(),
path: z.string().optional(),
instantiate: z.boolean().default(true),
},
(a) => s.importModel(a),
);
tool(
"script_upsert",
"Create or edit trusted JavaScript behavior and Inspector fields.",
{
...revision,
id: z.string(),
name: z.string(),
source: z.string().max(250000),
fields: json,
},
(a) => {
new Function("return (" + a.source + ");");
return tx(
a,
[
{
op: "script.upsert",
args: {
script: {
id: a.id,
name: a.name,
source: a.source,
fields: a.fields,
},
},
},
],
"Изменить скрипт " + a.name,
);
},
);
tool(
"prefab_create",
"Capture object subtree as prefab asset.",
{ ...revision, id: z.string(), name: z.string().optional() },
(a) =>
tx(
a,
[
{
op: "prefab.create",
args: { id: a.id, ...(a.name ? { name: a.name } : {}) },
},
],
"Создать префаб",
),
);
tool(
"prefab_instantiate",
"Instantiate prefab and remap internal object references.",
{ ...revision, assetId: z.string(), position: vector.default([0, 0, 0]) },
(a) =>
tx(
a,
[
{
op: "prefab.instantiate",
args: { assetId: a.assetId, position: a.position },
},
],
"Добавить префаб",
),
);
for (const action of ["undo", "redo"] as const)
tool(
"history_" + action,
action + " last transaction.",
{ expectedRevision: revision.expectedRevision },
(a) => {
if (a.expectedRevision !== s.store.project.revision)
throw Error("REVISION_CONFLICT");
s.store[action]();
return { revision: s.store.project.revision };
},
);
tool(
"project_save",
"Persist JSON and portable .forma archive to project directory.",
{},
() => s.save(),
);
tool(
"project_export_web",
"Export standalone HTML+runtime+assets as ZIP in exports/. Does not publish.",
{},
() => s.exportWeb(),
);
for (const action of ["play", "stop", "snapshot", "capture"])
tool(
"runtime_" + action,
action +
" in the connected editor. Requires a live browser editor. Capture returns actual PNG.",
{},
() => s.runtime(action),
["snapshot", "capture"].includes(action),
);
tool(
"runtime_input",
"Send timed movement, first-person view, jump, dash or attack to the running game and return observed state.",
{
x: z.number().min(-1).max(1).default(0),
z: z.number().min(-1).max(1).default(0),
attack: z.boolean().default(false),
jump: z.boolean().default(false),
dash: z.boolean().default(false),
sprint: z.boolean().default(false),
reset: z.boolean().default(false),
yaw: z.number().optional(),
pitch: z.number().min(-1.3).max(1.3).optional(),
pointer: z.boolean().default(false),
aim: vector.optional(),
durationMs: z.number().int().min(50).max(10000).default(500),
},
(a) => s.runtime("input", a),
);
tool(
"editor_focus",
"Focus editor camera on an entity.",
{ id: z.string() },
(a) => s.runtime("focus", a),
);
const resource = (name: string, uri: string, data: () => any) =>
server.registerResource(
name,
uri,
{ mimeType: "application/json" },
async () => ({
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(data(), null, 2),
},
],
}),
);
resource("Current project", "forma://project/current", () => s.store.project);
resource("Commands", "forma://reference/commands", () => commandReference);
resource("Scripts", "forma://reference/scripts", () => scriptReference);
server.registerPrompt(
"create_scene",
{
description: "Create and verify a 3D scene from an empty project.",
argsSchema: { theme: z.string().optional() },
},
({ theme }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text:
"Build a 3D scene" +
(theme ? " themed " + theme : "") +
". Read project metadata and command/script resources. Save the current project before replacing. Start empty, create or import geometry, configure materials and lighting, and add a camera. Add behaviors only when required by the scene. Use atomic revision-checked changes. Verify the scene and any scripted behavior with runtime tools; capture PNG when an editor is connected, save and export. Do not claim a check passed without evidence.",
},
},
],
}),
);
return server;
}