API Docs

Mesh Editor API

The Mesh Editor API provides temporary mesh processing and Unity package analysis, plus authenticated revision persistence. Static compatibility results are estimates and do not replace a Unity or VRChat SDK build.

All endpoints have no endpoint-specific rate limit. Deployment-level request and abuse protections still apply. None of these endpoints consume tokens or credits.

Start a mesh operation

POST /api/mesh-edit

Authentication: none.

Content type: multipart/form-data.

| Field | Type | Required | Description |

| --- | --- | --- | --- |

| file | File | Yes | Source GLB, maximum 300 MB. |

| settings | JSON string | Yes | Mesh operation settings. |

Settings schema:

| Field | Type | Required | Description |

| --- | --- | --- | --- |

| merge_by_distance | Boolean | Conditional | Welds vertices closer than merge_threshold on every mesh object. Objects stay separate. UVs, skin weights and shape keys are kept. Nothing is joined and normals are not recalculated. |

| merge_threshold | Number | With merge | Greater than 0 and at most 10. |

| decimate | Boolean | Conditional | Enables decimation. |

| decimate_ratio | Number | With decimation | From 0.01 through 1. |

| target_paths | String array | For selected-mesh decimation or scaling | 1 to 100 hierarchy paths. When omitted, decimation applies to every mesh object separately with the same ratio. Selected scaling always requires paths. |

| scale_selected | Boolean | Conditional | Enables object-local scaling for selected meshes. |

| compress_textures | Boolean | Conditional | Resizes textures whose longest side exceeds texture_max_dimension. UV maps are preserved. |

| texture_max_dimension | Integer | With texture compression | From 16 through 8192. Default 1024. |

| apply_parts | Boolean | Conditional | Enables the per-part list described below. Cannot be combined with decimate or target_paths. |

| parts | Array | With apply_parts | 1 to 1000 entries. Rejected unless apply_parts is true. |

| quest | Object | Conditional | One Make Quest version stage: {"stage":"merge","toggle_paths":[...]}, {"stage":"humanoid"}, {"stage":"atlas"}, or {"stage":"bones","max_bones":150}. See the Quest stage paragraph below. |

At least one operation must be enabled. Selected paths are resolved after Blender imports the GLB. Missing or ambiguous paths fail the operation. Selected scaling preserves the object hierarchy and rig data.

Decimation (decimate, and decimate_ratio in parts) welds each mesh by position, reduces it with a quadric edge collapse, then restores data from the original surface: UVs (island aware, so texture is never stretched across a UV seam), material slots, flat or smooth shading, vertex colours, skin weights, and supported basis-relative shape keys. Animated or non-basis-relative shape keys fail the operation explicitly. The result lists the key names and the largest displacement. Each target in the result also carries quality (surface_deviation_pct, the largest distance from the reduced to the original surface as a percentage of the part size; posed_deviation_pct, the same distance for a skinned part when the original and the reduced part are both in a fixed test pose, or null for a part without an armature; hard_crease_pct, the share of vertex positions where smooth faces met at a hard edge stored in split normals; pieces_before and pieces_after; border_edges_before and border_edges_after; uv_seam_fixes) and a warnings array. The operation fails instead of returning a damaged mesh when the part splits into more pieces than it had, its shape changes by more than 10%, or a skinned part differs by more than 10% when posed (skin weights that transferred badly). It warns above 3% shape change or posed difference, when border edges are added, when mixed flat and smooth shading had to be recalculated, or when hard_crease_pct is above 0.5 because the weld smooths those creases. Fully flat faces keep their flat shading. A mesh with open borders, such as clothing, can stop at a higher triangle count than requested because the collapse will not tear it. Compare before_triangles and after_triangles.

apply_parts applies a per-part list in one operation. It cannot be combined with decimate or target_paths. Each parts entry has a path (hierarchy path, as in target_paths) and optional remove (boolean, default false), decimate_ratio (number from 0.01 through 1, default 1), and shape_keys (see below). Paths not listed are unchanged. remove: true deletes the object and its node from the returned GLB; bones and other meshes are left alone. Removed entries must use decimate_ratio 1 and must not set shape_keys. shape_keys is null (default) to keep every shape key, an array of names to keep only those names (other non-Basis keys are removed), or [] to remove all non-Basis shape keys. The API has no hide flag: the Mesh Editor hides groups client-side and, when it exports, applies remove to a copy of the workspace model, so the workspace GLB and its history keep hidden groups. GLB, FBX, OBJ, Unity, rig upload, and saved revisions all use that copy.

quest runs one stage of the Make Quest version pipeline in the Mesh Editor. The Mesh Editor runs the stages in order, each as its own operation and history entry. merge joins skinned meshes that share a rig and keeps toggle_paths (up to 100 hierarchy paths) as separate meshes. humanoid renames bones to the VRChat Humanoid names (Hips, Spine, LeftUpperArm, LeftEye, Jaw, and so on) from the bone names and the skeleton, and renames the skin weight groups, shape key group references and animation paths with them so the skinning and animation are kept. It understands Mixamo, Rigify (DEF-), Blender .L/.R, VRM J_Bip and 3ds Max Biped names (including Biped's numbered fingers), skips helper, IK and twist bones, does not mistake eyebrow, eyelid or eye target bones for eyes, keeps bones that already have a Humanoid name, and leaves alone any bone whose role contradicts the skeleton. It also checks left and right against the bone positions. If the names contradict each other, for example the left arm lies on the same side as the right leg, the stage fails with The skeleton names disagree and changes nothing. If the names agree with each other but are mirrored against the character's own sides, the result carries a warnings entry. The result lists per armature renamed, roles, examples, missing (required Humanoid bones not found), dropped, warnings and pose_change_pct. It fails with No armature found when no mesh is skinned. atlas combines Principled BSDF base colour and emission into one atlas of at most 1024 px, clips emission to 1.0, and drops normal, metallic, and roughness maps. The Unity package assigns VRChat/Mobile/Toon Lit for Quest, which reads only a main texture and emission, so those dropped maps would not be used there. bones applies only when the armature exceeds max_bones (integer from 1 to 1000, default 150). It first removes unweighted bones, but only as many as needed, tip-like and deepest bones first, so attachment points that fit under the limit stay. If weighted bones still exceed the limit, it merges the skin weights of the least influential leaf bones into their parents, tips first, until it fits, and lists them in merged. Humanoid, eye, jaw and constrained bones are never removed. If those alone exceed the limit the stage fails with cannot be removed. Merged bones lose their own movement, so hair, skirt or accessory bones become rigid. merge, humanoid and bones check the rig before returning. They pose the model with a fixed test pose before and after and fail without returning a file if any vertex moves (limit 0.01% of the model size for merging and pruning, 0.001% for renames, with removed and merged bones left at rest). They also compare the skin weights of every vertex exactly: the same weights under the new names for a rename, the weights summed onto the surviving parent for a prune, the same weights at the same positions for a merge. The measured pose change is returned as pose_change_pct. Shape-safe reduction and texture resizing use the existing apply_parts and compress_textures fields. The quest object cannot be combined with merge_by_distance, decimate, scale_selected, compress_textures, apply_parts, parts, or target_paths; any of those returns 400. Materials with unsupported node graphs and UVs whose faces cross tiles fail the stage explicitly.

curl -X POST https://example.com/api/mesh-edit \
  -F "[email protected]" \
  -F 'settings={"decimate":true,"decimate_ratio":0.6,"target_paths":["Avatar/Armature/Hair"]}'
curl -X POST https://example.com/api/mesh-edit \
  -F "[email protected]" \
  -F 'settings={"apply_parts":true,"parts":[{"path":"Avatar/Armature/Body","decimate_ratio":0.5,"shape_keys":["vrc.v_aa","vrc.blink_left"]},{"path":"Avatar/Armature/Hair","remove":true},{"path":"Avatar/Armature/Props","decimate_ratio":0.3}]}'

Response:

{
  "id": "c780f4c3-2bd5-4f0f-90ca-92fd19fd40c0",
  "inputSize": 4182048
}

Common errors:

| Status | Cause |

| --- | --- |

| 400 | Missing fields, invalid settings JSON, invalid operation limits, invalid target paths, or file larger than 300 MB. |

Stream mesh-operation progress

GET /api/mesh-edit/{id}

Authentication: none.

Response content type: text/event-stream.

For texture compression, result.textures lists each resized image with name, before, and after dimensions.

When merge_by_distance ran, result.merged lists each mesh with path, before_vertices and after_vertices.

data: {"time":1785175200000,"message":"Targeted decimate 'Armature/Hair': 18000 -> 10800 triangles","percent":null}

data: {"done":true,"status":"done","error":null,"result":{"targets":[{"path":"Armature/Hair","before_triangles":18000,"after_triangles":10800,"shape_keys_kept":[],"max_shape_key_displacement":0,"quality":{"surface_deviation_pct":0.04,"pieces_before":1,"pieces_after":1,"border_edges_before":0,"border_edges_after":0,"uv_seam_fixes":12},"warnings":[]}],"merged":[]}}

Errors: 404 for an unknown or expired operation. Operations expire after 10 minutes.

Download a mesh-operation result

GET /api/mesh-edit/{id}/file

Authentication: none.

Successful response: model/gltf-binary attachment named edited-model.glb. Downloading removes the temporary operation record.

Errors: 404 unknown or expired operation, 409 still processing, or 500 Blender operation failure.

Check a GLB against Quest limits

POST /api/mesh-edit/quest-check

Authentication: none.

Content type: multipart/form-data.

Same-origin requests require the CSRF cookie and matching X-CSRF-Token header.

| Field | Type | Required | Description |

| --- | --- | --- | --- |

| file | File | Yes | GLB to check, maximum 300 MB. |

Runs the static Quest check on the uploaded GLB without Blender, using the same limits as the analysis panel. pass is true only when the Quest rank is Medium or better, there are no blockers, no texture is larger than 1024 px, and every texture size can be read (WebP textures cannot be read and count as unknown). This is an estimate and does not replace VRChat SDK validation in Unity.

curl -X POST https://example.com/api/mesh-edit/quest-check \
  -H 'Cookie: crateyard_csrf=example-token' \
  -H 'X-CSRF-Token: example-token' \
  -F "[email protected]"

Response (abbreviated):

{
  "pass": false,
  "reasons": ["Quest rank is poor; Medium or better is required", "1 texture(s) exceed 1024px"],
  "quest": { "rank": "poor", "rankIndex": 3, "stats": {} },
  "blockers": [],
  "issues": [],
  "textures": {
    "count": 2,
    "unknown": 0,
    "maxDimension": 2048,
    "oversized": [{ "name": "body_albedo", "width": 2048, "height": 2048 }],
    "sizes": [{ "name": "body_albedo", "width": 2048, "height": 2048 }]
  },
  "facts": { "triangles": 21000, "vertices": 0, "meshes": 3, "skinnedMeshes": 1, "basicMeshes": 2, "materialSlots": 4, "bones": 140 }
}

Errors: 400 for a missing or non-multipart file, a file over 300 MB, or a file that cannot be read as a GLB (Could not read GLB: ...); 403 for an invalid CSRF token.

Analyze a Unity package

POST /api/mesh-editor/analyze-package

Authentication: none.

Content type: multipart/form-data with a required file field containing a .unitypackage, maximum 300 MB.

The scanner reconstructs Unity asset paths and performs bounded static analysis. It scans at most 20,000 archive entries, 16 MB per text asset, and 96 MB of text in total. It inventories scenes, prefabs, SDK and Avatar Descriptor signals, shader references, particles, PhysBones, contacts, constraints, and mobile-removed components. It extracts the largest included GLB, FBX, or OBJ for temporary preview when present. Up to 128 companion textures, MTL files, and binary buffers are retained for preview, with a combined 96 MB limit; Unity-generated .preview.* thumbnails are excluded. Unity _MainTex and _BaseMap material references are mapped to extracted textures by asset GUID, falling back to a material-name to texture-name match. The report also counts included models, animations, controllers, audio clips, mesh assets, and materials so model-less packages can be explained.

curl -X POST https://example.com/api/mesh-editor/analyze-package \
  -F "[email protected]"

Response:

{
  "id": "ee3062c1-9223-4d70-bc4e-1f57090fbb56",
  "report": {
    "checkerVersion": 1,
    "fileCount": 24,
    "scenes": 1,
    "prefabs": 1,
    "hasAvatarDescriptor": true,
    "sdk": "sdk3",
    "customShaders": 0,
    "particleSystems": 0,
    "physBones": 0,
    "contacts": 0,
    "constraints": 0,
    "mobileRemovedComponents": [],
    "companionFiles": 1,
    "companionBytes": 4182048,
    "companionsLimited": false,
    "includedModelCount": 1,
    "animationCount": 0,
    "controllerCount": 0,
    "audioClipCount": 0,
    "meshAssetCount": 0,
    "materialCount": 1,
    "coverage": {
      "analyzedTextBytes": 28410,
      "textLimitBytes": 100663296,
      "entriesLimited": false
    }
  },
  "modelName": "avatar.fbx",
  "modelExtension": "fbx",
  "modelMime": "application/octet-stream",
  "modelUrl": "/api/mesh-editor/package/ee3062c1-9223-4d70-bc4e-1f57090fbb56/model",
  "companionFiles": [
    {
      "name": "Assets/Avatar/body.png",
      "extension": "png",
      "mime": "image/png",
      "url": "/api/mesh-editor/package/ee3062c1-9223-4d70-bc4e-1f57090fbb56/companion/0"
    }
  ],
  "materialBindings": [
    {
      "materialName": "Body",
      "textureCompanionIndex": 0
    }
  ],
  "expiresInSeconds": 600
}

Common errors: 400 missing, wrong-extension, empty, or oversized file. 422 invalid archive or bounded scan failure.

Read an extracted package model

GET /api/mesh-editor/package/{id}/model

Authentication: none.

Returns the temporarily extracted model with its model content type. Package model sessions expire after 10 minutes and are not permanent storage.

Errors: 404 unknown, expired, or model-less package session.

Read an extracted package companion

GET /api/mesh-editor/package/{id}/companion/{index}

Authentication: none.

Returns a temporarily extracted texture, MTL file, or binary model companion by its zero-based response index. Clients use these files while converting the package model for browser preview. Package sessions expire after 10 minutes. This endpoint does not consume tokens.

Errors: 404 unknown session, expired session, or invalid companion index.

List saved workspaces, model files, and storage usage

GET /api/mesh-editor/workspaces

Authentication: required session.

Response:

{
  "workspaces": [
    {
      "id": 42,
      "name": "avatar-polish",
      "current_revision_id": 81,
      "current_file_id": 153
    }
  ],
  "files": [
    {
      "id": 153,
      "originalName": "avatar-polish.glb",
      "fileExtension": "glb",
      "fileSize": 4182048,
      "sizeLabel": "4 MB",
      "createdAt": "2026-08-01T10:00:00.000Z",
      "url": "/asset-creator/file/153",
      "workspaceId": 42
    }
  ],
  "usage": {
    "usedBytes": 4182048,
    "fileCount": 1,
    "limitBytes": 5368709120,
    "remainingBytes": 5364527072
  }
}

Storage usage sums active permanent physical file rows once. Workspace and revision references do not duplicate byte accounting.

Errors: 401 when authentication is missing.

Read inline Auto-Rigger status

GET /api/mesh-editor/rig/{taskId}

Authentication: required session. The shared task authorization resolver requires access to the requested Auto-Rigger task.

Request body: none.

This endpoint polls an Auto-Rigger task created from the Mesh Editor. It does not start or retry work and consumes no tokens. There is no endpoint-specific rate limit. Deployment-level protections still apply.

curl -b cookie.txt https://example.com/api/mesh-editor/rig/92

Response:

{
  \"taskId\": 92,
  \"status\": \"processing\",
  \"phase\": \"rigging\",
  \"phaseDetail\": \"Generating humanoid armature\",
  \"phaseProgressPct\": 62,
  \"error\": null,
  \"outputModelUrl\": null
}

When processing finishes, outputModelUrl points to the authorized rigged-model response so the editor can load it as the active revision.

Common errors:

| Status | Cause |

| --- | --- |

| 401 | Authentication is missing. |

| 404 | The identifier is invalid, inaccessible, missing, or is not an Auto-Rigger task. |

Save a workspace revision

POST /api/mesh-editor/workspaces/save

Authentication: required session. Same-origin browser requests must include the CSRF token in the _csrf multipart field.

Content type: multipart/form-data.

| Field | Type | Required | Description |

| --- | --- | --- | --- |

| file | File | Yes | Current GLB, maximum 300 MB. |

| name | String | No | Workspace or model name, maximum 200 characters. |

| workspace_id | Positive integer | No | Existing workspace owned by the current user. Omit to create a workspace. |

| operation | JSON string | No | Operation summary stored with the revision. |

| compatibility_report | JSON string | No | Client package coverage context. Model facts are re-read and analyzed on the server. |

| _csrf | String | Browser POST | Current CSRF cookie value. |

The server checks the user's active permanent storage usage before upload, writes the GLB to Bunny storage, creates a files row, creates a revision linked to the owned workspace, and returns updated usage.

Response:

{
  "workspace": {
    "id": 42,
    "name": "avatar-polish",
    "current_revision_id": 81
  },
  "revision": {
    "id": 81,
    "workspace_id": 42,
    "parent_revision_id": 80,
    "file_id": 153
  },
  "file": {
    "id": 153,
    "url": "/asset-creator/file/153"
  },
  "usage": {
    "usedBytes": 4182048,
    "fileCount": 1,
    "limitBytes": 5368709120
  }
}

Common errors:

| Status | Cause |

| --- | --- |

| 400 | Missing or empty GLB, or file larger than 300 MB. |

| 401 | Authentication required. The local browser model is unchanged. |

| 404 | Requested workspace does not belong to the current user or does not exist. |

| 413 | Saving would exceed the configured permanent storage limit. |

| 422 | Invalid GLB. |