Rupa3D API — local physics + licensed generative router
This service accepts a buyer-owned GLB and returns an audited GLB, a measured single-hull or VHACD convex-decomposition collider, or a ready-for-physics GLB with receiver-verifiable Proof Profile v1 evidence. It also accepts a binary PNG/JPEG and routes image-to-3D through the only model currently licensed for paid use, TripoSR. A technical preview is available at https://api.migan3d.com; the MCP endpoint is https://mcp.migan3d.com/mcp. API access requires an authorized key. Public checkout, Polar billing, RunPod generation, and text-to-3D are not enabled on this preview. The GLB audit path has been verified over HTTPS; the other operations described below remain library capabilities and do not imply a live generative provider.
Quick start (local credit rail)
Node 22.14 or newer is required because this slice uses experimental node:sqlite. No package was added.
$env:RUPA3D_API_DATA = 'C:\safe\rupa3d-api-data'
$env:RUPA3D_HMAC_SECRET = '<generate and store outside the repository>'
$env:RUPA3D_VERIFIER_COMMIT = '<40-hex commit containing this verifier>'
$env:RUPA3D_RUNPOD_KEY = '<store outside the repository>'
$env:RUPA3D_RUNPOD_TRIPOSR_ENDPOINT = '<configured RunPod endpoint base URL>'
node api/admin.mjs beri '<buyer-key>' 50
node api/jalan.mjs
Image-to-3D uses binary upload only; the server never fetches a buyer URL:
curl -H "Authorization: Bearer $RUPA3D_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @reference.png http://127.0.0.1:8787/v1/gambar
curl -H "Authorization: Bearer $RUPA3D_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: buyer-image-0001" \
-d '{"gambar_id":"gambar_...","operasi":"gambar_ke_3d","model":"triposr"}' \
http://127.0.0.1:8787/v1/job
model-generatif.json is a closed registry. TripoSR is enabled. Step1X-3D, Hi3DGen, and TRELLIS.2 are recorded with their blocking dependency and cannot be selected by a credited job, regardless of request parameters. Endpoints are looked up by environment-variable name from the registry; values and the RunPod key are never stored in repository files.
Upload and process:
curl -H "Authorization: Bearer $RUPA3D_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @model.glb http://127.0.0.1:8787/v1/aset
curl -H "Authorization: Bearer $RUPA3D_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: buyer-order-0001" \
-d '{"aset_id":"aset_...","operasi":"siap_fisika","parameter":{"meter_per_unit":1,"kerapatan_kg_m3":1000}}' \
http://127.0.0.1:8787/v1/job
The other accepted scale bases are dimensi_diketahui: {"sumbu":"x|y|z","meter":2.4} and a convincing human-scale kelas. The service never guesses scale.
The mass basis is exactly one of three inputs:
kerapatan_kg_m3, supplied directly.bahan, selected from the small, cited preset table intugas.mjs.massa_kg, the buyer's own stated mass. What it produces depends on the mesh:
- In every case the stated mass is recorded under asset.extras.rupa3d.fisika_dinyatakan with terukur: false. It is never issued as an api/massa claim: that claim would only repeat the input, and the Proof Profile would lose where the number came from. - Closed mesh: the service measures the volume and derives density = massa_kg / volume. Inertia and pose are then issued as receiver-verifiable claims whose metode names this derivation. - Open or inward-wound mesh: only the statement is recorded.
Optional kelas_benda (for example "drum_baja_200_liter") adds laporan.saran_massa: a sourced mass range for that object class. It is only offered when both hold:
- the class is narrow (max/min ≤ 3);
- its reference dimensions match the measured bounding box within 1.5×.
It is a suggestion that needs confirmation. It is never copied to massa_kg, never embedded in the GLB, and never a claim. When massa_kg is also given, massa_dinyatakan_di_luar_rentang flags a stated mass outside the range. Currently 15 of 41 researched classes qualify. api/saran-massa.json is derived from riset/massa-kelas/ by node api/bangun-saran-massa.mjs; do not edit it by hand.
collider and siap_fisika accept mode: "cembung" | "dekomposisi" | "otomatis". The default is otomatis: measure one hull first, and run VHACD only when that hull is marked (including volume-hull-berlebih). Optional parameter_vhacd records/controls resolution, concavity, maxConvexHulls, alpha, beta, both downsampling values, and convexHullApproximation; defaults are defined once in dekomposisi-cembung.mjs. Volume-union sampling uses fixed sampel_volume=65536 and seed_volume=1381322817 unless supplied.
MCP Streamable HTTP
Point an MCP client at http://127.0.0.1:8787/mcp and send the same Authorization: Bearer <key> header. The tool is rupa_aset_siap_fisika; it accepts glb_base64, operasi, parameter, and idempotency_key. rupa_gambar_ke_3d accepts gambar_base64, optional model: "triposr", and idempotency_key. Both use the same reservations, ownership, limits, and job implementation as REST. While text-to-3D is enabled and ready, rupa_teks_ke_3d (teks, optional pengarang, idempotency_key) returns a running job and rupa_job (job_id) reports its status and URLs.
The HTTP MCP transport accepts at most 100 JSON-RPC messages per batch. A batch of 101 or more returns HTTP 400 with JSON-RPC code -32600 before tool execution. This security limit comes from the pinned MCP SDK. The existing Rupa3D request-byte limit still applies; this does not introduce the SDK body-parser limit because Rupa3D passes an already parsed body.
Text-to-3D (teks_ke_3d, disabled by default)
A short brief is written into a Kiln program by DeepSeek-V4.1-Flash (MIT weights, official API; the only author that passed preregistered benchmark B-PENULIS-1), rendered by Kiln 0.8.0, audited by Rupa3D, and returned with the program that rebuilds it. Design, threat model, and adopted review findings: docs/RANCANGAN-TEKS-KE-3D.md.
curl -X POST http://127.0.0.1:8787/v1/job -H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: text-crate-0001" -H "Content-Type: application/json" \
-d '{"operasi":"teks_ke_3d","teks":"a small wooden crate"}' # 202, status berjalan
curl http://127.0.0.1:8787/v1/job/<id> -H "Authorization: Bearer $KEY" # poll until sukses|gagal
- Output: GLB whose coordinates are read as metres (glTF 2.0), claims
api/audit-kotak-batas and api/teks/jumlah-segitiga (both required), program.kiln.js, and lembar.png. Provenance at asset.extras.rupa3d.generatif_teks records the author, Kiln version and runtime identity, SHA-256 of program, raw GLB, and brief, and Kiln QA. - bangun_ulang.status: terbukti-di-server means the server re-rendered the unedited program and hashed the result itself. That result must match the raw GLB on the same Kiln and runtime identity; cross-OS equality is not yet measured.
- AI disclosure: the asset and program are generated by an AI model, may
contain errors or omissions, and are for reference only. The brief is sent to DeepSeek, which processes data in the PRC. Do not put confidential or personal data in a brief.
- Controls:
- A per-job budget proxy holds the provider key; Kiln only gets a per-job token. - Caps are $0.60 per job and $5 per UTC day by default. Refusals return 402, which is not retried. - Queue is 1 global and 1 per owner; the deadline is 1,800 s. - A failed or over-budget job releases its credits. - Production requires Kiln's Linux isolated evaluator; the feature stays closed unless a readiness probe passes in the configured mode.
Enable only after the price and buyer gate are approved (env names in deploy/env.example; values never enter the repository).
Credits and settlement
| Operation | Credits |
|---|---|
audit | 1 |
collider | 3 |
siap_fisika | 5 |
gambar_ke_3d | 3 |
teks_ke_3d | 10 (proposal; disabled) |
The generative price is derived from the stated A40 rate and observed timing: $1.22 / 3600 = $0.0003389/s; a warm 49–63 second job costs $0.01660–$0.02135, while the conservative 60 second cold start plus 63 second job costs $0.04168. One $9/50 = $0.18 credit is about 4.32 times that worst stated GPU cost, leaving room for failed-provider attempts, verification, storage, and transfer. A failed Rupa3D job releases the held credit.
The local rail uses SQLite hold -> settle | release. The Polar adapter asks Polar for the meter balance, subtracts this customer's active local reservations, and only ingests one usage event after a job succeeds. The event uses external_id = job id. A post-success delivery failure becomes a local reconciliation debt; retry keeps the same external ID. Raw keys are never stored or written to application logs.
Proof and limits
The output stores Proof Profile v1 at asset.extras.rupa3d.bukti and the collider contract at asset.extras.rupa3d.collider. A receiver should call hitungUlang(claim, { jalur_glb }); the canonical hash excludes the Rupa3D namespace, avoids self-reference, and still changes when one BIN byte changes.
- Input is GLB only, at most 25 MiB, one million vertices, one million
triangles, and node depth 256. The server never fetches user URLs (no SSRF).
POST /v1/gambaraccepts only binary PNG/JPEG detected by magic bytes, at
most 10 MiB and 4096×4096 pixels read from the file header. Images are owned by the caller and expire after 24 hours, like GLB assets.
- The RunPod adapter calls configured
/run, then polls/status/{job_id}
under one deadline. It requires output.mesh_base64, validates a real GLB with the same raw limits as uploaded assets, and fails closed otherwise.
- Generated GLBs explicitly remain unitless. Their Proof Profile contains a
receiver-recomputable world bounding box in source units and triangle count. Mass is never inferred: even a closed mesh still needs physical scale and density, while an open mesh is rejected independently.
- Generative provenance is duplicated in the JSON report and
asset.extras.rupa3d.generatif: model, provider-reported version/commit (or an explicit not-reported marker), closed runtime license path, source-image SHA-256, canonical output fingerprint, provider job id, and timestamp.
- A production job uses a child process with a 60 second timeout and a 384 MiB
old-space limit. The in-process runner exists only for deterministic tests.
- Storage is per UUID with a 24-hour sweeper. CORS is disabled.
GET /v1/sehatis the only unauthenticated route. It returns a fixed minimal
process-health response and no storage, customer, credit, or build data.
- Every job output is recomputed before settlement, in a second child process
with the same time and memory limits (recomputation re-runs VHACD, Rapier hulls, pose, and mesh integration, which must never stall the HTTP loop). Credits settle only when the record itself says every claim passed. The append-only audit/verifikasi-job.jsonl gets one line per verified job, pass or fail — including timeouts — with the input SHA-256, canonical output fingerprint, verifier commit, and every claim verdict. The 24-hour sweeper never removes this hash/verdict trail, and backups contain it but never buyer geometry.
- Automatic mode keeps one hull for convex objects and uses Rapier 0.20 VHACD
for marked concave objects. Each public Compound part stores its transformed vertices and indices. Verification measures mesh-to-union, sampled union-surface-to-mesh, and Monte Carlo union volume with its standard error.
- The 0.5% diagonal distance threshold is Rupa3D policy, not a derived physical
constant. Surface sampling is finite, and Monte Carlo volume is an estimate.
- Rapier 0.20 traps in WASM for observed
resolution > 256, so this API guards
16..256. Its maxConvexHulls=1 was observed not to cap output; Rupa3D enforces that negative control as one hull and marks the concave result.
- Mass and inertia are only measured when every primitive is closed and wound outward.
- Closure is welded in each primitive's local space. - Overlapping closed primitives are not a CSG union and can double-count mass. - An open or inward-wound primitive does not fail the job: mass, inertia and pose are omitted, and the report names the primitive (alasan_massa_tidak_tersedia).
- Rigid animation (node translation/rotation/scale) is accepted.
- Translation keyframes are scaled with the mesh. - Collider, mass, inertia and pose are measured on the default pose (the node's default TRS). The collider does not follow moving parts, and the report says so. - Skins and morph targets change the mesh itself and are rejected with HTTP 422 mesh_tidak_statis.
- The pose recipe is analytic on the hull. It is separately checked by a Rapier drop plus an off-support negative
control, sized to the asset: - the floor is at least 20× the diagonal; - the drop starts 2 % of the diagonal above the lowest point; - the test center of mass sits half a diagonal beyond a support edge.
- If the drop does not confirm the pose, only the pose claim is omitted (
alasan_pose_tidak_tersedia). - Out-of-scope input returns a structured 4xx (
kode,pesan), never a bare 500. Every failed job is appended to
audit/verifikasi-job.jsonl, and its reserved credits are released.
- Proof Profile v1 provides integrity and reproducibility, not publisher
signatures, revocation, or identity.
Run offline tests with:
node --test --experimental-test-isolation=none api/test-api.mjs api/test-situs-deploy.mjs api/test-generatif.mjs
The child-process case is visibly marked and requires RUPA3D_UJI_PROSES_ANAK=1 outside a sandbox that permits spawning.
Product site, measured examples, and deploy package
api/situs/ is a dependency-free static product and documentation site. Its checkout configuration is api/situs/checkout-config.json; empty channel values deliberately render a “Coming soon” state. Do not add placeholder payment URLs.
Regenerate repository-owned table/chair evidence and receiver verification:
node api/contoh/buat-contoh.mjs
node api/contoh/hitung-ulang.mjs api/contoh/hasil/kursi-siap-fisika.glb
Render the two PNG comparisons with the dedicated Blender script, regenerate documentation from this README plus openapi.yaml, and serve locally:
blender --background --python bpy/render_api_examples.py -- api/contoh/hasil/render.json api/situs/aset
node api/situs/bangun-docs.mjs
node api/situs/serve.mjs
api/deploy/ contains PM2/systemd units, nginx, an env-name-only template, backup script, and a canary/smoke/rollback runbook. The technical preview uses an isolated service account and a persistent server-managed HMAC. Checkout remains disabled; the local instructions above do not require access to the hosted service.
OpenAPI contract
The exact repository contract is included below.
openapi: 3.1.0
info:
title: Rupa3D Physics and Licensed Generative API
version: 1.2.0-local
description: >-
Technical preview is available with authorized API keys. Public checkout, Polar billing, and RunPod generation are not enabled. The text-to-3D
operation teks_ke_3d is disabled by default (503 fitur_mati) until its price and the buyer gate are approved.
servers:
- url: https://api.migan3d.com
description: Technical preview; authorized key required
- url: http://127.0.0.1:8787
description: Local development
security:
- bearerAuth: []
paths:
/v1/sehat:
get:
security: []
summary: Minimal unauthenticated health check
responses:
'200': { description: Service process is accepting HTTP requests }
/v1/aset:
post:
summary: Upload one GLB without asking the server to fetch a URL
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, contentMediaType: model/gltf-binary, contentEncoding: binary }
responses:
'201': { description: Asset stored for 24 hours }
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { description: Body exceeds 25 MiB }
'422': { description: Body is not a GLB }
/v1/gambar:
post:
summary: Upload one PNG/JPEG without asking the server to fetch a URL
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, contentEncoding: binary }
responses:
'201': { description: Magic-checked image stored for its owner for 24 hours }
'401': { $ref: '#/components/responses/Unauthorized' }
'413': { description: Body exceeds 10 MiB or dimensions exceed 4096 by 4096 }
'422': { description: Body is not a structurally recognized PNG/JPEG header }
/v1/job:
post:
summary: Run GLB processing, licensed image-to-3D generation, or asynchronous text-to-3D (disabled by default)
parameters:
- in: header
name: Idempotency-Key
required: true
schema: { type: string, minLength: 8, maxLength: 200 }
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
aset_id: { type: string }
gambar_id: { type: string }
operasi: { enum: [audit, collider, siap_fisika, gambar_ke_3d, teks_ke_3d] }
teks:
type: string
minLength: 3
maxLength: 600
description: >-
teks_ke_3d brief, 3..600 code points after whitespace is collapsed. Rejected when it contains
control, zero-width, bidi, or Unicode tag characters, or starts with '-'. The brief is sent to
DeepSeek (data processed in the PRC); do not include confidential or personal data.
pengarang:
enum: [deepseek-v4.1-flash]
default: deepseek-v4.1-flash
description: Paid author registry currently permits DeepSeek-V4.1-Flash only (MIT weights, official API).
model:
enum: [triposr]
default: triposr
description: Paid registry currently permits TripoSR only.
parameter:
type: object
description: Collider controls; siap_fisika additionally requires one scale basis and density or a material preset.
properties:
mode:
enum: [cembung, dekomposisi, otomatis]
default: otomatis
description: Automatic keeps a passing single hull, otherwise runs VHACD.
arah: { type: integer, minimum: 8, maximum: 4096, default: 96 }
ambang_persen_diagonal:
type: number
exclusiveMinimum: 0
maximum: 10
default: 0.5
description: Rupa3D policy threshold, not a derived physical constant.
parameter_vhacd:
type: object
properties:
alpha: { type: number, minimum: 0, maximum: 1, default: 0.05 }
beta: { type: number, minimum: 0, maximum: 1, default: 0.05 }
concavity: { type: number, minimum: 0, maximum: 1, default: 0.01 }
planeDownsampling: { type: integer, minimum: 1, maximum: 32, default: 4 }
convexHullDownsampling: { type: integer, minimum: 1, maximum: 32, default: 4 }
maxConvexHulls: { type: integer, minimum: 1, maximum: 256, default: 32 }
resolution:
type: integer
minimum: 16
maximum: 256
default: 128
description: Guarded at 256 because larger values trapped Rapier 0.20 WASM in local probes.
convexHullApproximation: { type: boolean, default: true }
additionalProperties: false
sampel_volume: { type: integer, minimum: 4096, maximum: 1000000, default: 65536 }
seed_volume: { type: integer, minimum: 0, maximum: 4294967295, default: 1381322817 }
meter_per_unit: { type: number, exclusiveMinimum: 0 }
dimensi_diketahui: { type: object }
kelas: {}
kerapatan_kg_m3: { type: number, exclusiveMinimum: 0, description: Mass basis 1 of 3 — give exactly one of kerapatan_kg_m3, bahan, massa_kg. }
bahan: { type: string, description: Mass basis 2 of 3 — cited preset material. }
massa_kg:
type: number
exclusiveMinimum: 0
description: >-
Mass basis 3 of 3 — mass stated by the buyer. Always stored as
asset.extras.rupa3d.fisika_dinyatakan (terukur false) and never issued as an api/massa
claim. On a closed mesh the measured volume gives density = massa_kg / volume, and inertia
and pose are issued as verifiable claims whose metode names that derivation.
kelas_benda:
type: string
pattern: '^[a-z0-9_]{2,60}$'
description: >-
Optional object class (e.g. drum_baja_200_liter) for laporan.saran_massa — a sourced mass
range, offered only for narrow classes (max/min <= 3) whose reference dimensions match the
measured box within 1.5x. A suggestion requiring confirmation; never copied to massa_kg,
never in the GLB, never a claim.
oneOf:
- required: [aset_id, operasi, parameter]
properties:
operasi: { enum: [audit, collider, siap_fisika] }
- required: [gambar_id, operasi]
properties:
operasi: { const: gambar_ke_3d }
- required: [teks, operasi]
properties:
operasi: { const: teks_ke_3d }
responses:
'200': { description: Existing or newly completed idempotent job (terminal status sukses or gagal) }
'202':
description: >-
Accepted and not yet terminal (teks_ke_3d). Status moves menahan -> berjalan -> menyelesaikan ->
sukses|gagal; poll GET /v1/job/{id}. Output is AI-generated and may contain errors or omissions.
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { description: Insufficient available credits after active reservations }
'403': { description: teks_ke_3d in owner-only local mode and the key is not the owner's }
'409': { description: Idempotency-Key already used for a different teks_ke_3d brief }
'422': { description: Invalid operation, scale basis, density, model license gate, image, GLB, or brief }
'429': { description: Per-key rate limit exceeded, or the teks_ke_3d queue is full }
'503': { description: teks_ke_3d disabled or not ready (fitur_mati), or the daily generation budget is spent }
/v1/job/{id}:
get:
summary: Read job status owned by the authenticated customer
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
'200': { description: Job status }
'404': { description: Job not found for this customer }
/v1/job/{id}/hasil.glb:
get:
summary: Download a successful job's GLB
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: GLB with embedded Rupa3D proof
content:
model/gltf-binary:
schema: { type: string, contentEncoding: binary }
'404': { description: Result unavailable or not owned by this customer }
/v1/job/{id}/program.kiln.js:
get:
summary: Download the Kiln program that rebuilds a successful teks_ke_3d job's raw GLB
description: >-
Served as a plain-text attachment. The unedited program reproduces the raw Kiln GLB (before Rupa3D
metadata) with the same Kiln version and runtime identity recorded in the provenance.
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: Program source
content:
text/plain: { schema: { type: string } }
'404': { description: Not a successful teks_ke_3d job of this customer }
/v1/job/{id}/lembar.png:
get:
summary: Download the Kiln contact sheet of a successful teks_ke_3d job
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: PNG contact sheet
content:
image/png: { schema: { type: string, contentEncoding: binary } }
'404': { description: Not a successful teks_ke_3d job of this customer }
/v1/saldo:
get:
summary: Meter balance minus active local reservations
responses:
'200': { description: meter, held, and available credits }
/mcp:
post:
summary: MCP Streamable HTTP endpoint
description: >-
Exposes rupa_aset_siap_fisika and rupa_gambar_ke_3d with the same bearer authentication and credit ledger.
rupa_teks_ke_3d and rupa_job are listed only while teks_ke_3d is enabled and ready.
Batches contain at most 100 JSON-RPC messages; larger batches are rejected before tool execution.
responses:
'200': { description: MCP JSON or streamable response }
'202': { description: Notification batch accepted without response body }
'400': { description: Invalid JSON-RPC request, including batch over 100 messages (code -32600) }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
responses:
Unauthorized:
description: Missing or invalid license/API key