Files
LocalAI/core/http/react-ui/e2e/threed-gen.spec.js
Richard Palethorpe 9058a2bb46 feat: Add 3d generation UI/API and trellis2cpp backend (#10979)
* feat(3d): add Generate3D RPC, FLAG_3D capability, and /v1/3d/generations endpoint

Adds the plumbing for image-conditioned 3D asset generation (binary
glTF / GLB output), modeled on the video generation path:

- backend.proto: Generate3D RPC + Generate3DRequest (staged image src,
  glb dst, seed/step/cfg_scale/texture_steps, quality and background
  enums, params map for backend-specific extras)
- pkg/grpc: thread Generate3D through client, server, embed, base and
  the backend interfaces; connection-evicting and distributed-node
  wrappers (in-flight tracking + file staging) included
- core/config: FLAG_3D usecase (guessed only for the trellis2cpp
  backend), '3d' canonical usecase string mapped to the Generate3D
  method, and a '3d' output modality
- REST: POST /v1/3d/generations (+ unversioned alias) returning
  OpenAIResponse with a /generated-3d URL or b64_json; conditioning
  image accepted as URL, base64, or data URI; quality/background
  validated at the edge; .glb served as model/gltf-binary
- auth: '3d' route feature (default ON); /api/instructions entry

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(trellis2cpp): add the trellis2.cpp image-to-3D backend

Wraps localai-org/trellis2cpp (C++/GGML port of Microsoft TRELLIS.2,
pbr-textures branch) as a Go+purego backend, following the
stablediffusion-ggml pattern:

- backend/go/trellis2cpp: purego bindings to the flat C ABI (v9,
  asserted at startup), eager pipeline load with model-set validation
  (refuses non-trellis GGUFs; degrades coarse/geometry-only/textured
  exactly like the upstream demo), Generate3D via t2_generate +
  t2_bake_glb writing a binary glTF to dst. Weight-free unit tests
  cover resolution/validation/param mapping — CI never downloads the
  multi-GB GGUF set or runs inference.
- CPU SIMD variants build into per-variant directories (the shared
  libggml sonames collide across variants, unlike sd-ggml's flat
  renamed-.so scheme); run.sh picks one via /proc/cpuinfo.
- CI wiring: backend-matrix entries (cpu, cuda12/13, vulkan
  amd64+arm64, l4t, l4t-cuda13, darwin metal), index.yaml meta +
  latest/master image entries, bump_deps tracking of the pbr-textures
  branch, changed-backends.js mapping, top-level Makefile targets.
- Importer: auto-detects trellis GGUF repos/URIs (registered before
  llama-cpp so the .gguf match isn't stolen) and expands any trellis
  URI to the full 10-file component set spanning the three LocalAI-io
  HF repos.
- Gallery: trellis2-4b (full PBR + 1024 cascade) and
  trellis2-4b-geometry (512 untextured) with verified sha256s.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(ui): 3D generation page with native GLB viewer and IndexedDB history

Adds a Studio tab + /app/3d page for the new image-to-3D endpoint:

- GlbViewer ports the trellis2cpp demo's dependency-free WebGL2
  renderer (quaternion trackball, metallic-roughness PBR, ACES,
  hidden-line wireframe with a bounded index budget) and pairs it with
  a minimal GLB parser for the two forms t2_bake_glb emits — dense
  vertex-PBR (linear COLOR_0 + _METALLIC_ROUGHNESS, uploaded as
  normalized integers) and the opt-in UV-atlas textured form. Parsing
  happens before any GL so stats and errors render without WebGL2.
- use3DHistory stores past generations (params, input thumbnail, and
  the GLB blob itself) in IndexedDB with keep-newest-20 eviction —
  GLBs are multi-MB binaries localStorage can't hold — and the page
  offers a download button for the active GLB.
- Wiring: CAP_3D capability constant (FLAG_3D — the exact string
  /api/models/capabilities serves), threeDApi, router entries, Studio
  tab, vite dev proxy, en locale keys.
- e2e: render-smoke entry plus a focused spec that feeds a real
  one-triangle vertex-PBR GLB through the parser/viewer and exercises
  IndexedDB persistence, selection, deletion, and API errors.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(3d): address API correctness and UX issues

Keep 3D generation on the LocalAI-specific /3d/generations route and ensure authentication and permissions cover it.

Propagate distributed transfer failures, publish a portable ARM64 backend image, honor importer overrides, and align discovery, upload validation, and touch controls.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(3d): add previewable print remeshing

Add a single-detail CGAL Alpha Wrap workflow for existing Trellis GLBs, including PBR reprojection, API documentation, tracing, and an in-browser preview before download.

Allow the remesh route to enforce its 512 MiB upload cap independently of the smaller global default so generated high-resolution meshes can be processed.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* build(trellis2cpp): centralize remesh dependency pins

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(kokoros): implement Generate3D stub for new proto RPC

The Generate3D RPC added to backend.proto for the trellis2cpp backend
made tonic's generated Backend trait require generate3_d, breaking the
kokoros-grpc build. Return unimplemented like the other unsupported
modalities.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-07-29 16:15:04 +02:00

320 lines
12 KiB
JavaScript

import { test, expect } from './coverage-fixtures.js'
// 3D generation page: mock the capabilities + generation endpoints, feed a
// real (tiny) Form-B GLB through the parser/viewer, and exercise the
// IndexedDB-backed history. All assertions are DOM/text — never pixels — so
// the suite passes with or without working WebGL2 in headless Chromium.
function mockCapabilities(page) {
return page.route('**/api/models/capabilities', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: [{ id: 'trellis-test-model', capabilities: ['FLAG_3D'] }] }),
})
})
}
// A valid one-triangle GLB in trellis2cpp's vertex-PBR form: POSITION/NORMAL
// f32, COLOR_0 u16-normalized VEC4, _METALLIC_ROUGHNESS u8-normalized VEC2,
// u32 indices — the layout mesh_export.cpp's write_vertex_glb emits.
function buildTinyGlb() {
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])
const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1])
const colors = new Uint16Array([
65535, 0, 0, 65535,
0, 65535, 0, 65535,
0, 0, 65535, 65535,
])
const metalRough = new Uint8Array([0, 153, 0, 153, 0, 153])
const indices = new Uint32Array([0, 1, 2])
const views = []
let binLength = 0
const addView = (typed) => {
const byteOffset = binLength
views.push({ buffer: 0, byteOffset, byteLength: typed.byteLength, target: 34962 })
binLength += typed.byteLength
binLength += (4 - (binLength % 4)) % 4
return views.length - 1
}
addView(positions); addView(normals); addView(colors); addView(metalRough)
const idxView = addView(indices)
views[idxView].target = 34963
const json = {
asset: { version: '2.0', generator: 'threed-gen.spec' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0, NORMAL: 1, COLOR_0: 2, _METALLIC_ROUGHNESS: 3 }, indices: 4, material: 0 }] }],
materials: [{ pbrMetallicRoughness: { baseColorFactor: [1, 1, 1, 1], metallicFactor: 0, roughnessFactor: 0.6 }, doubleSided: true }],
accessors: [
{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3', min: [0, 0, 0], max: [1, 1, 0] },
{ bufferView: 1, componentType: 5126, count: 3, type: 'VEC3' },
{ bufferView: 2, componentType: 5123, normalized: true, count: 3, type: 'VEC4' },
{ bufferView: 3, componentType: 5121, normalized: true, count: 3, type: 'VEC2' },
{ bufferView: 4, componentType: 5125, count: 3, type: 'SCALAR' },
],
bufferViews: views,
buffers: [{ byteLength: binLength }],
}
const bin = Buffer.alloc(binLength)
const parts = [positions, normals, colors, metalRough, indices]
for (let i = 0; i < parts.length; i++) {
Buffer.from(parts[i].buffer, parts[i].byteOffset, parts[i].byteLength).copy(bin, views[i].byteOffset)
}
let jsonText = JSON.stringify(json)
while (jsonText.length % 4 !== 0) jsonText += ' '
const jsonBuf = Buffer.from(jsonText)
const total = 12 + 8 + jsonBuf.length + 8 + bin.length
const glb = Buffer.alloc(total)
glb.writeUInt32LE(0x46546c67, 0) // magic 'glTF'
glb.writeUInt32LE(2, 4)
glb.writeUInt32LE(total, 8)
glb.writeUInt32LE(jsonBuf.length, 12)
glb.writeUInt32LE(0x4e4f534a, 16) // 'JSON'
jsonBuf.copy(glb, 20)
const binHeader = 20 + jsonBuf.length
glb.writeUInt32LE(bin.length, binHeader)
glb.writeUInt32LE(0x004e4942, binHeader + 4) // 'BIN\0'
bin.copy(glb, binHeader + 8)
return glb
}
// 1x1 transparent PNG for the conditioning-image upload.
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
function mockGeneration(page, onRequest) {
return page.route('**/3d/generations', (route) => {
if (route.request().method() !== 'POST') return route.continue()
onRequest?.(route.request().postDataJSON())
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
created: Math.floor(Date.now() / 1000),
data: [{ url: '/generated-3d/test.glb' }],
}),
})
})
}
function mockGlbDownload(page) {
return page.route('**/generated-3d/test.glb', (route) => {
route.fulfill({
status: 200,
headers: { 'Content-Type': 'model/gltf-binary' },
body: buildTinyGlb(),
})
})
}
async function generateOnce(page) {
await page.goto('/app/3d')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({
name: 'input.png',
mimeType: 'image/png',
buffer: TINY_PNG,
})
await page.locator('button[type="submit"]').click()
}
test.describe('3D generation', () => {
test.beforeEach(async ({ page }) => {
await mockCapabilities(page)
await mockGlbDownload(page)
})
test('generates, shows mesh stats, and offers a GLB download', async ({ page }) => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
await generateOnce(page)
// Stats render from the parsed GLB even without working GL.
await expect(page.getByTestId('glb-stats')).toContainText('3', { timeout: 15_000 })
await expect(page.getByTestId('glb-stats')).toContainText('1')
await expect(page.getByTestId('glb-stats')).toContainText('PBR')
const download = page.getByTestId('glb-download')
await expect(download).toBeVisible()
await expect(download).toHaveAttribute('href', /^blob:/)
await expect(download).toHaveAttribute('download', /\.glb$/)
expect(requestBody.model).toBe('trellis-test-model')
expect(requestBody.image).toBeTruthy()
expect(requestBody.quality).toBe('auto')
expect(requestBody.background).toBe('auto')
expect(requestBody.response_format).toBe('url')
})
test('advanced settings map to step/texture_steps/cfg_scale/seed', async ({ page }) => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
await page.goto('/app/3d')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({ name: 'input.png', mimeType: 'image/png', buffer: TINY_PNG })
await page.locator('select').first().selectOption('512')
await page.getByRole('button', { name: /Advanced Settings/ }).click()
const advanced = page.locator('#threed-advanced-options')
await advanced.locator('input').nth(0).fill('20') // steps
await advanced.locator('input').nth(1).fill('8') // texture steps
await advanced.locator('input').nth(2).fill('5.5') // guidance
await advanced.locator('input').nth(3).fill('42') // seed
await page.locator('button[type="submit"]').click()
await expect(page.getByTestId('glb-download')).toBeVisible({ timeout: 15_000 })
expect(requestBody.quality).toBe('512')
expect(requestBody.step).toBe(20)
expect(requestBody.texture_steps).toBe(8)
expect(requestBody.cfg_scale).toBe(5.5)
expect(requestBody.seed).toBe(42)
})
test('applies a single-detail watertight remesh and previews it before download', async ({ page }) => {
await mockGeneration(page)
let remeshRequest = null
await page.route('**/3d/remesh', (route) => {
remeshRequest = route.request()
route.fulfill({
status: 200,
headers: { 'Content-Type': 'model/gltf-binary' },
body: buildTinyGlb(),
})
})
await generateOnce(page)
await expect(page.getByTestId('glb-remesh')).toBeVisible({ timeout: 15_000 })
await page.getByLabel('Remesh detail').fill('100')
await page.getByTestId('glb-remesh').click()
await expect(page.getByTestId('glb-remesh')).toContainText('Show original')
await expect(page.getByTestId('glb-download')).toHaveAttribute('download', /-remeshed\.glb$/)
expect(remeshRequest).not.toBeNull()
expect(remeshRequest.method()).toBe('POST')
expect(remeshRequest.headers()['content-type']).toContain('multipart/form-data')
const multipart = remeshRequest.postDataBuffer().toString()
expect(multipart).toContain('trellis-test-model')
expect(multipart).toContain('0.35')
await page.getByTestId('glb-remesh').click()
await expect(page.getByTestId('glb-remesh')).toContainText('Apply remeshing')
await expect(page.getByTestId('glb-download')).not.toHaveAttribute('download', /-remeshed\.glb$/)
})
test('history entry persists across navigation and reloads into the viewer', async ({ page }) => {
await mockGeneration(page)
await generateOnce(page)
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
// IndexedDB persists within the browser context — navigate away and back.
await page.goto('/app')
await page.goto('/app/3d')
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
// Selecting the entry loads the stored Blob back into the viewer.
await page.getByTestId('media-history-item').click()
await expect(page.getByTestId('glb-stats')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('glb-download')).toHaveAttribute('href', /^blob:/)
})
test('deleting a history entry removes it', async ({ page }) => {
await mockGeneration(page)
await generateOnce(page)
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
await page.getByTestId('media-history-delete').click()
await expect(page.getByTestId('media-history-item')).toHaveCount(0)
})
test('API errors surface through the trace link error box', async ({ page }) => {
await page.route('**/3d/generations', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'trellis2 pipeline load: missing files' } }),
})
})
await generateOnce(page)
await expect(page.locator('.media-result')).toContainText(/missing files|error/i, { timeout: 15_000 })
})
test('rejects oversized conditioning images before making an API request', async ({ page }) => {
let requested = false
await mockGeneration(page, () => { requested = true })
await page.goto('/app/3d')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({
name: 'oversized.png',
mimeType: 'image/png',
buffer: Buffer.alloc(32 * 1024 * 1024 + 1),
})
await expect(page.locator('.toast')).toContainText('32 MiB limit')
expect(requested).toBe(false)
})
test('hides and guards 3D generation when the feature is disabled', async ({ page }) => {
await page.route('**/api/auth/status', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
authEnabled: true,
staticApiKeyRequired: false,
user: { id: 'restricted-user', role: 'user', permissions: { '3d': false } },
}),
})
})
await page.goto('/app/studio?tab=threed')
await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0)
await expect(page.locator('.studio-tab', { hasText: 'Images' })).toHaveClass(/studio-tab-active/)
await page.goto('/app/3d')
await expect(page).toHaveURL(/\/app\/?$/)
})
test.describe('touch input', () => {
test.use({ hasTouch: true })
test('accepts two-finger touch gestures in the GLB viewer', async ({ page }) => {
await mockGeneration(page)
await generateOnce(page)
const canvas = page.getByTestId('glb-canvas')
await expect(canvas).toBeVisible({ timeout: 15_000 })
await canvas.scrollIntoViewIfNeeded()
const box = await canvas.boundingBox()
expect(box).not.toBeNull()
const client = await page.context().newCDPSession(page)
await client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ id: 1, x: box.x + box.width * 0.35, y: box.y + box.height * 0.5 },
{ id: 2, x: box.x + box.width * 0.65, y: box.y + box.height * 0.5 },
],
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ id: 1, x: box.x + box.width * 0.3, y: box.y + box.height * 0.45 },
{ id: 2, x: box.x + box.width * 0.7, y: box.y + box.height * 0.55 },
],
})
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await expect(page.getByRole('button', { name: 'Auto-rotate' })).toHaveClass(/btn-secondary/)
})
})
})