SLOPSHOPPER

blender-preview

Prévia da cena do Blender no Claude Code e checagem de GLB contra os orçamentos do jogo

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blender-preview
│ ┃ Blender ✕ › fix the failing auth test and add an audit log call │ ┃ Atualizando pelo Blender… │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Atualizar ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /blender │ ⎿ blender-preview: Prévia do Blender aberta. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blender
Atualizando pelo Blender… [ Atualizar ]
README

claude-mods

Live mods for Claude Code: see how long your plan will last, hand work off between machines, and give Claude eyes on Unity and Blender.

License: MIT Claude Code plugins Mods Tests

English · Português

<img src="docs/terminal.svg" alt="Claude Code in a terminal: a band above the prompt shows the 5-hour limit at 38% lasting ~4.7h and the weekly limit at 41% lasting ~52h, three open sessions with tokens and cost, a model recommendation, and a Unity status line" width="100%">


Claude Code mods are plugins of function hooks: TypeScript that runs inside Claude Code and can draw panes and bands, add slash commands, watch tool calls, and feed context back to the model. This repository is a marketplace of five of them, built for daily use on a Max plan and on game projects.

ModWhat it gives you
usage-limitsYour 5-hour and weekly limits with how long they will last at your current pace, tokens and cost across every open session, a per-project breakdown, API-credit tracking, and a model recommendation that keeps you from running dry before the reset.
fluxo/handoff writes where you stopped so the next session on another machine picks it up, plus a sound and notification when a long turn finishes.
tarefasThe project's tasks in a plain TASKS.md: a pane, the next task above the prompt with Start and Done buttons, and Claude creating and finishing tasks as it works.
unity-toolsCompiler errors handed to Claude seconds after it edits a .cs, EditMode/PlayMode tests it can run on its own, a dedicated-server build pane, and guards that keep it out of Library/, scenes and .meta files.
blender-previewA live thumbnail of the Blender scene with triangle, bone and texture budgets, and a check of every .glb Claude exports.

[!NOTE] The mods' interface is in Brazilian Portuguese. Translations are very welcome; see Contributing.

Install

You need a recent Claude Code (tested on 2.1.286 – 2.1.295). In a terminal, start claude and install the mods you want:

/plugin install usage-limits --marketplace kevinmedeiros/claude-mods
/plugin install fluxo --marketplace kevinmedeiros/claude-mods
/plugin install tarefas --marketplace kevinmedeiros/claude-mods
/plugin install unity-tools --marketplace kevinmedeiros/claude-mods
/plugin install blender-preview --marketplace kevinmedeiros/claude-mods

Answer y to add the marketplace the first time and pick the user scope. From then on the mods run in every Claude Code session on that machine: the terminal, the Code tab of Claude Desktop, and the IDE extensions. Restart sessions that were already open.

Update with /plugin update <mod>; change options with /plugin → the mod → configure.


usage-limits

<img src="docs/desktop-pane.svg" alt="The /limites pane in Claude Desktop: a 5-hour card at 38% with a bar, the projection hatched to the reset and a pace marker; a weekly card at 41% in red warning that at the current pace it runs out about 80 hours before the reset; and the session's token mix" width="560" align="right">

The percentages come from Anthropic's own rate-limit headers, so they cover the whole account: every session, every machine, and claude.ai. On top of them the mod answers the question the usage page doesn't: will this last until the reset?

  • Status line and a band above the prompt with each limit's %, how many hours it lasts at the current pace, time to reset, and tokens and cost across all open sessions.
  • /limites pane with bars that show used, projected-to-reset and an even-pace marker, plus the 5-hour pace, the weekly pace in points per day, and how many hours of work are left at the last hour's pace.
  • Every open session on the machine, with project, model, tokens, cost and share of the total, plus today's total.
  • Per-project spend for each window, so you know which project is burning the week.
  • Model advice. When the pace won't reach the reset, it recommends the least disruptive change, such as "use Opus to orchestrate and Haiku to execute", and shows how long the limit would last.
  • Economy mode (/economia on). The main thread keeps its model while new subagents run on Claude Haiku 5.5.
  • API credits. Optional, with a Console Admin API key: spend of the monthly credit by model, the daily pace, and when the credit runs out.
  • ↻ Refresh to pick up the newest reading any session on the machine has seen.
  • Toasts at 80%, at 95%, when a limit is exhausted, and when the projection says it ends before the reset.
OptionDefault
adminApiKey—sk-ant-admin… key of the Console organization that receives the plan's API credits. Kept in secure storage.
apiMonthlyCredit200Max 5x: 100 · Max 20x: 200
apiCycleDay1Day of the month the credit renews
  • 5-hour window: the pace of the last hour, or the window average when the window is young. Hours left = (100 − used) ÷ pace.
  • Weekly window: points the account spent in the last 24 hours, never less than one day of history, so a busy first morning isn't treated as the pace of the whole week. This includes other machines and overnight runs.
  • Hours of work (weekly, extra): the mod measures how many weekly points each 5-hour point costs, inside a single 5-hour window, and multiplies that by the 5-hour pace. It reacts within minutes when you switch models.
  • Model scenarios: spend is priced per model at API list prices from the last three active hours, and each scenario reprices it with a cheaper model. Subscription limits may weight models differently, so treat the numbers as estimates.
  • Freshness: a session learns the account's percentages only from its own API responses. Sessions on the same machine share the newest reading, and the band says "reading from 2h ago" when it is older than 10 minutes.

fluxo

Continue on another machine where you stopped.

  • /handoff [note] (or /passagem, if another plugin already has /handoff) has Claude Haiku 5.5 summarize the session (where you stopped, what was done, next steps, gotchas) and writes it to .claude/handoff.md with the files you changed and the git state.
  • Automatic handoff (off by default). When autoHandoff is on, every turn that edits files refreshes the file list, so there's a record even if the session dies.
  • On the other machine, the next session in that project shows "↪ Handoff from MacBook (3h ago): …" above the prompt, with Continue from here, View and Dismiss buttons.
  • Long-turn alert: a toast, plus a chime on macOS, when a turn runs longer than 3 minutes. Optionally spoken.

The handoff file travels with your project, through a git push or a synced folder. If you'd rather keep it out of git, add .claude/handoff.md to .gitignore and sync the folder instead.

OptionDefault
longTurnMinutes3
sound / speaktrue / false
autoHandofffalse (turn on to refresh the file after every turn that edits)
handoffPath.claude/handoff.md

tarefas

The project's to-do list, kept in a TASKS.md at the project root (the same format as Claude's task-management skill, so you, Claude and your editor can all edit it).

  • Above the prompt: "▶ Next: port the dodge to FishNet · 7 open" with Start (writes the prompt for you), ✓ Done and Tasks.
  • /tarefas opens the pane: Active, Waiting On, Someday and recently done, a field to add a task, Start, ✓ and ↑ (move from Someday to Active).
  • /tarefa <title> [- context] adds one from the prompt.
  • Claude keeps it current. It gets task_add, task_done and task_list tools, so it records work it leaves for later and checks tasks off when it finishes them, with a toast every time. Turn this off with claudeCanEdit.
  • /tarefas importar pulls pending items from your notes (sections such as Pendente, Próximos passos or Next steps) and open checkboxes in BACKLOG.md / ROADMAP.md into Someday, without duplicates.
  • With fluxo, /handoff carries the open tasks to the other machine.
OptionDefault
tasksPathTASKS.md
showBandtrue
claudeCanEdittrue

unity-tools

Turns on in any session whose folder holds a Unity project. It finds ProjectSettings/ above or up to two levels below the session's folder.

  • Compiler errors in Claude's context. After Claude edits a .cs, the mod compiles that assembly with dotnet build from the .csproj Unity generates, out of tree under Temp/, in about a second. The errors come back with the tool result. New files Unity hasn't listed yet are included.
  • /testes [editmode|playmode|tudo] runs the Unity Test Runner in batch mode and shows the results in the /unity pane, with a send failures to Claude button. Claude can also call the unity_tests tool itself to check its own changes.
  • /build-servidor builds the Linux dedicated server and streams the log. It tells you if the Linux Dedicated Server module is missing.
  • Guards refuse, with a reason Claude can act on:
  • editing Library/, Temp/, Logs/ or UserSettings/;
  • hand-writing .unity or .prefab files;
  • creating or deleting .meta files by hand;
  • UnityEngine inside an engine-free core folder.
OptionDefault
projectPathautoProject folder, if auto-detection misses
unityPathUnity Hub path for the project's version
coreDirAssets/Scripts/CoreEngine-free folder; empty turns the guard off
compileChecktrue
allowSceneEditsfalse
serverBuildPathBuilds/Server/Server.x86_64
buildMethod—e.g. MyGame.Editor.BuildServer.Build; empty uses -buildLinux64Player

Requirements:

  • Unity Hub with the project's editor version.
  • The .NET SDK (dotnet), for the compile check.
  • Generated project files: Preferences → External Tools → Regenerate project files.
  • Batch tests need the editor closed for that project.

blender-preview

Works with Blender connected to Claude Code through a Blender MCP server.

  • After every scene change Claude makes through Blender MCP, the mod renders a thumbnail and measures triangles per object, bones, materials, textures and actions against your budget. /blender opens the pane.
  • On every .glb export, it reads the file and hands Claude a report: size, triangles, skin and bones, animations, and texture sizes, with a warning for anything over budget.
  • /glb <file> checks any .glb.
OptionDefault
maxTriangles20000
maxBones100
maxTextureSize2048
autoPreviewtrue
serverBlender

Privacy

Everything stays on your machine.

  • Local files. The mods write to ~/.claude/usage-limits/, to each plugin's own store, to .claude/handoff.md and TASKS.md in your project.
  • Network requests:
  • usage-limits calls the Console cost API, and only when you set an Admin key.
  • fluxo calls Claude Haiku 5.5 when you run /handoff.
  • Nothing else leaves your machine.
  • Local processes: unity-tools runs dotnet and the Unity editor; blender-preview talks to your local Blender through MCP.

Development

plugins/<mod>/
  .claude-plugin/plugin.json   manifest and options
  hooks/register.tsx           the hooks module
  hooks/*.ts                   pure logic (unit-tested)
  tests/*.test.ts              run by `claude plugin test`
  types/index.d.ts             the mod's state contract
claude plugin validate plugins/usage-limits
claude plugin test plugins/usage-limits
claude --plugin-dir plugins/usage-limits

With --plugin-dir, a mod hot-reloads as you save. To regenerate the images in this README, run bun docs/generate-previews.ts; they are drawn with the mods' own code. See CONTRIBUTING.md.

License

MIT © Kevin Medeiros. Not affiliated with Anthropic.

Source 3 files
hooks/register.tsx 220 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Budgets, GlbCheck } from '../types'
5import { STATS_CODE, exportPath, fromBase64, glbText, parseGlb, parseStats, warnings } from './glb'
6
7const PANE = 'blender-preview'
8const PREFIX = 'mcp__Blender__'
9/** Ferramentas do Blender MCP que só leem ou renderizam: não mudam a cena. */
10const READ_ONLY = /^(get_|render_|search_|jump_)/
11
12const preview = atom({ plugin: 'blender-preview', key: 'preview' } as const, null)
13const glb = atom({ plugin: 'blender-preview', key: 'glb' } as const, null)
14const busy = atom({ plugin: 'blender-preview', key: 'busy' } as const, false)
15
16const live = { server: 'Blender', budgets: { maxTriangles: 20_000, maxBones: 100, maxTextureSize: 2048 } as Budgets, auto: true }
17
18const textOf = (content: { type: string; text?: string }[]) =>
19  content
20    .filter(c => c.type === 'text')
21    .map(c => c.text ?? '')
22    .join('\n')
23
24const tempFile = async ($: EngineInterface) => {
25  const dir = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
26  return `${dir.replaceAll('\\', '/').replace(/\/+$/, '')}/claude-blender-preview.png`
27}
28
29/** Mede a cena e renderiza a miniatura pelo Blender MCP. */
30const refresh = async ($: EngineInterface) => {
31  if (await read($, busy)) return
32  await update($, busy, () => true)
33  const at = await $.clock.now()
34  try {
35    const statsResult = await $.mcp.call(live.server, 'execute_blender_code', { code: STATS_CODE })
36    const stats = statsResult.isError ? undefined : parseStats(textOf(statsResult.content))
37    const file = await tempFile($)
38    let png: string | undefined
39    const thumb = await $.mcp.call(live.server, 'render_thumbnail_to_path', { output_path: file })
40    if (!thumb.isError && (await $.fs.exists(file))) {
41      const { base64 } = await $.fs.read(file, { as: 'bytes' })
42      png = base64
43    }
44    await update($, preview, () => ({
45      png,
46      stats,
47      at,
48      problem: stats || png ? undefined : `O Blender respondeu com erro: ${textOf(statsResult.content).slice(0, 200)}`,
49    }))
50  } catch (error) {
51    await update($, preview, () => ({
52      at,
53      problem: `Blender MCP indisponível (o Blender está aberto com o add-on conectado?): ${String(error).slice(0, 160)}`,
54    }))
55  } finally {
56    await update($, busy, () => false)
57  }
58}
59
60/** Lê um .glb do disco e confere contra os orçamentos. */
61const inspectGlb = async ($: EngineInterface, path: string): Promise<GlbCheck> => {
62  const at = await $.clock.now()
63  let check: GlbCheck
64  try {
65    const stat = await $.fs.stat(path)
66    if (stat.size > 4 * 1024 * 1024) {
67      check = { path, at, problem: `${(stat.size / 1_048_576).toFixed(1)} MB: grande demais para ler aqui (limite de 4 MB).` }
68    } else {
69      const { base64 } = await $.fs.read(path, { as: 'bytes' })
70      const report = parseGlb(fromBase64(base64), stat.size)
71      check = report ? { path, at, report } : { path, at, problem: 'O arquivo não é um .glb válido.' }
72    }
73  } catch (error) {
74    check = { path, at, problem: `Não consegui ler: ${String(error).slice(0, 160)}` }
75  }
76  await update($, glb, () => check)
77
78  return check
79}
80
81const checkText = (c: GlbCheck) => (c.report ? glbText(c.path, c.report, live.budgets) : `Export GLB ${c.path}: ${c.problem}`)
82
83export const register: Register = (on, options) => {
84  live.server = String(options.server ?? 'Blender')
85  live.auto = options.autoPreview !== false
86  live.budgets = {
87    maxTriangles: Number(options.maxTriangles ?? 20_000),
88    maxBones: Number(options.maxBones ?? 100),
89    maxTextureSize: Number(options.maxTextureSize ?? 2048),
90  }
91  let pending: { cancel: () => void } | undefined
92
93  on('session.start', async ($, e, next) => {
94    const started = await next(e)
95    await $.command.register({ name: 'blender', description: 'Prévia do Blender: miniatura da cena, triângulos, ossos e texturas' })
96    await $.command.register({ name: 'glb', description: 'Confere um .glb contra os orçamentos do jogo', argumentHint: '<caminho.glb>' })
97
98    return started
99  })
100
101  // Depois de cada mudança na cena pelo Blender MCP: atualiza a prévia e confere o GLB exportado.
102  on('tool.call', async ($, e, next) => {
103    const ran = await next(e)
104    if (!e.tool.startsWith(PREFIX) || ran.deny !== undefined || ran.isError) return ran
105    const name = e.tool.slice(PREFIX.length)
106    if (READ_ONLY.test(name)) return ran
107
108    if (live.auto) {
109      pending?.cancel()
110      pending = $.clock.after(1500, () => void refresh($))
111    }
112    const code = (e as unknown as { code?: unknown }).code
113    const path = typeof code === 'string' ? exportPath(code) : undefined
114    if (!path) return ran
115    const check = await inspectGlb($, path)
116
117    return { ...ran, context: [...(ran.context ?? []), checkText(check)] }
118  }).catch(($, e, next) => next(e))
119
120  on('command.run', { command: 'blender' }, async $ => {
121    void refresh($)
122    await $.ui.open({ id: PANE, title: 'Blender' })
123    return { text: 'Prévia do Blender aberta.' }
124  })
125
126  on('command.run', { command: 'glb' }, async ($, e) => {
127    const path = e.args.trim().replace(/^["']|["']$/g, '')
128    if (!path) return { text: 'Uso: /glb <caminho do arquivo .glb>' }
129    const check = await inspectGlb($, path)
130    await $.ui.open({ id: PANE, title: 'Blender' })
131    return { text: checkText(check) }
132  })
133
134  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
135    const p = await read($, preview)
136    const g = await read($, glb)
137    const isBusy = await read($, busy)
138    const width = Math.max(20, Math.min(70, (e.props.bodyColumns ?? 60) - 2))
139    const statsWarnings = p?.stats ? warnings(p.stats, live.budgets) : []
140
141    const image = (() => {
142      if (!p?.png) return undefined
143      if (e.surface === 'terminal') {
144        const { Image } = $.ui.resolve(e)
145        return <Image key="thumb" source={{ png: p.png }} columns={width} rows={Math.round(width / 2.4)} alt="Miniatura da cena do Blender" />
146      }
147      if (e.surface === 'desktop' && p.png.length < 120_000) {
148        const { Svg } = $.ui.resolve(e)
149        const px = width * 7
150        const source =
151          `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${px} ${Math.round(px * 0.56)}" width="${px}" height="${Math.round(px * 0.56)}">` +
152          `<image width="100%" height="100%" preserveAspectRatio="xMidYMid meet" href="data:image/png;base64,${p.png}" xlink:href="data:image/png;base64,${p.png}"/></svg>`
153        return <Svg key="thumb" source={source} alt="Miniatura da cena do Blender" width={px} height={Math.round(px * 0.56)} />
154      }
155      return undefined
156    })()
157
158    const { Box, Text, Button } = $.ui.resolve(e)
159
160    return (
161      <Box flexDirection="column" rowGap={1}>
162        {isBusy && <Text color="warning">Atualizando pelo Blender…</Text>}
163        {p?.problem && <Text color="error">{p.problem}</Text>}
164        {!p && !isBusy && <Text dimColor>Sem prévia ainda. Abra o Blender com o add-on MCP e clique em Atualizar.</Text>}
165        {image}
166        {p?.stats && (
167          <Box flexDirection="column">
168            <Text bold>
169              Cena: {p.stats.triangles.toLocaleString('pt-BR')} triângulos · {p.stats.bones} ossos · {p.stats.materials} materiais ·{' '}
170              {p.stats.actions.length} animações
171            </Text>
172            {p.stats.objects.slice(0, 5).map(o => (
173              <Text key={o.name} dimColor>
174                {'  '}
175                {o.name}: {o.triangles.toLocaleString('pt-BR')} tris
176              </Text>
177            ))}
178            {statsWarnings.map(w => (
179              <Text key={w} color="warning">
180                ⚠ {w}
181              </Text>
182            ))}
183            {statsWarnings.length === 0 && (
184              <Text color="success">
185                ✓ Dentro do orçamento ({live.budgets.maxTriangles.toLocaleString('pt-BR')} tris, {live.budgets.maxBones} ossos, texturas até{' '}
186                {live.budgets.maxTextureSize})
187              </Text>
188            )}
189          </Box>
190        )}
191        {g && (
192          <Box flexDirection="column">
193            <Text bold>Último GLB</Text>
194            {checkText(g)
195              .split('\n')
196              .map((line, i) => (
197                <Text key={`g${i}`} color={line.startsWith('⚠') ? 'warning' : undefined} dimColor={!line.startsWith('⚠')}>
198                  {line}
199                </Text>
200              ))}
201          </Box>
202        )}
203        <Box flexDirection="row" columnGap={2}>
204          <Button key="refresh" label="Atualizar" onPress={() => void refresh($)} />
205          {statsWarnings.length > 0 && (
206            <Button
207              key="send"
208              label="Mandar avisos pro Claude"
209              variant="primary"
210              onPress={() =>
211                void $.prompt.fill({ text: `A cena do Blender está fora do orçamento do jogo:\n${statsWarnings.map(w => `- ${w}`).join('\n')}` })
212              }
213            />
214          )}
215        </Box>
216      </Box>
217    )
218  })
219}
220
hooks/glb.ts 195 lines
1import type { Budgets, GlbReport, SceneStats } from '../types'
2
3const u32 = (b: Uint8Array, at: number) => (b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16) | (b[at + 3]! << 24)) >>> 0
4const be32 = (b: Uint8Array, at: number) => ((b[at]! << 24) | (b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!) >>> 0
5const be16 = (b: Uint8Array, at: number) => (b[at]! << 8) | b[at + 1]!
6
7const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
8
9/** Base64 para bytes, sem depender de atob nem de Uint8Array.fromBase64. */
10export const fromBase64 = (text: string): Uint8Array => {
11  const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
12  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
13  let o = 0
14  for (let i = 0; i < clean.length; i += 4) {
15    const n =
16      (B64.indexOf(clean[i]!) << 18) |
17      (B64.indexOf(clean[i + 1] ?? 'A') << 12) |
18      ((B64.indexOf(clean[i + 2] ?? 'A') & 63) << 6) |
19      (B64.indexOf(clean[i + 3] ?? 'A') & 63)
20    out[o++] = (n >> 16) & 255
21    if (i + 2 < clean.length) out[o++] = (n >> 8) & 255
22    if (i + 3 < clean.length) out[o++] = n & 255
23  }
24
25  return out.subarray(0, o)
26}
27
28/** Largura e altura de um PNG ou JPEG pelos bytes do cabeçalho. */
29export const imageSize = (b: Uint8Array): { width: number; height: number } | undefined => {
30  if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
31    return { width: be32(b, 16), height: be32(b, 20) }
32  }
33  if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
34    let i = 2
35    while (i + 9 < b.length) {
36      if (b[i] !== 0xff) return undefined
37      const marker = b[i + 1]!
38      const length = be16(b, i + 2)
39      if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
40        return { height: be16(b, i + 5), width: be16(b, i + 7) }
41      }
42      i += 2 + length
43    }
44  }
45
46  return undefined
47}
48
49type Gltf = {
50  meshes?: { name?: string; primitives?: { indices?: number; attributes?: { POSITION?: number } }[] }[]
51  accessors?: { count?: number }[]
52  materials?: unknown[]
53  images?: { name?: string; uri?: string; bufferView?: number; mimeType?: string }[]
54  bufferViews?: { byteOffset?: number; byteLength?: number }[]
55  skins?: { joints?: number[] }[]
56  animations?: { name?: string }[]
57}
58
59/** Lê um .glb (binário do glTF 2.0): malhas, triângulos, ossos, animações e texturas. */
60export const parseGlb = (bytes: Uint8Array, sizeBytes = bytes.length): GlbReport | undefined => {
61  if (bytes.length < 20 || u32(bytes, 0) !== 0x46546c67) return undefined
62  const jsonLength = u32(bytes, 12)
63  if (u32(bytes, 16) !== 0x4e4f534a) return undefined
64  const gltf = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength))) as Gltf
65  let bin: Uint8Array | undefined
66  const binAt = 20 + jsonLength
67  if (binAt + 8 <= bytes.length && u32(bytes, binAt + 4) === 0x004e4942) {
68    bin = bytes.subarray(binAt + 8, binAt + 8 + u32(bytes, binAt))
69  }
70
71  let triangles = 0
72  const meshes = (gltf.meshes ?? []).map((mesh, i) => {
73    let tris = 0
74    for (const prim of mesh.primitives ?? []) {
75      const accessor = prim.indices ?? prim.attributes?.POSITION
76      tris += Math.floor((gltf.accessors?.[accessor ?? -1]?.count ?? 0) / 3)
77    }
78    triangles += tris
79    return { name: mesh.name ?? `mesh ${i}`, triangles: tris }
80  })
81  const textures = (gltf.images ?? []).map((img, i) => {
82    const view = img.bufferView !== undefined ? gltf.bufferViews?.[img.bufferView] : undefined
83    const data = view && bin ? bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + (view.byteLength ?? 0)) : undefined
84    const size = data ? imageSize(data) : undefined
85    return { name: img.name ?? img.uri ?? `imagem ${i}`, width: size?.width, height: size?.height }
86  })
87
88  return {
89    sizeBytes,
90    triangles,
91    meshes: meshes.sort((a, b) => b.triangles - a.triangles),
92    materials: gltf.materials?.length ?? 0,
93    textures,
94    bones: Math.max(0, ...(gltf.skins ?? []).map(s => s.joints?.length ?? 0)),
95    animations: (gltf.animations ?? []).map((a, i) => a.name ?? `animação ${i}`),
96  }
97}
98
99/** Avisos contra os orçamentos do jogo. */
100export const warnings = (
101  stats: { triangles: number; bones: number; textures: { name: string; width?: number; height?: number }[] },
102  budgets: Budgets,
103): string[] => {
104  const out: string[] = []
105  if (stats.triangles > budgets.maxTriangles) {
106    out.push(`${stats.triangles.toLocaleString('pt-BR')} triângulos, acima do orçamento de ${budgets.maxTriangles.toLocaleString('pt-BR')}`)
107  }
108  if (stats.bones > budgets.maxBones) out.push(`${stats.bones} ossos, acima do limite de ${budgets.maxBones}`)
109  for (const t of stats.textures) {
110    const side = Math.max(t.width ?? 0, t.height ?? 0)
111    if (side > budgets.maxTextureSize) out.push(`textura ${t.name} com ${t.width}×${t.height}, acima de ${budgets.maxTextureSize}`)
112  }
113
114  return out
115}
116
117const kb = (n: number) => (n >= 1_048_576 ? `${(n / 1_048_576).toFixed(1)} MB` : `${Math.round(n / 1024)} KB`)
118
119export const glbText = (path: string, r: GlbReport, budgets: Budgets) => {
120  const warn = warnings(r, budgets)
121  const lines = [
122    `Export GLB ${path}: ${kb(r.sizeBytes)}, ${r.triangles.toLocaleString('pt-BR')} triângulos em ${r.meshes.length} malha(s), ` +
123      `${r.materials} material(is), ${r.textures.length} textura(s), ${r.bones} osso(s), ${r.animations.length} animação(ões)` +
124      (r.animations.length > 0 ? ` (${r.animations.slice(0, 8).join(', ')})` : '') +
125      '.',
126  ]
127  if (r.animations.length > 0 && r.bones === 0) lines.push('⚠ Há animações mas nenhuma skin: confira se o rig foi exportado.')
128  for (const w of warn) lines.push(`⚠ ${w}`)
129
130  return lines.join('\n')
131}
132
133/** O script que o Blender roda para medir a cena. */
134export const STATS_CODE = `
135import bpy
136deps = bpy.context.evaluated_depsgraph_get()
137objs = []
138tris = 0
139for o in bpy.context.scene.objects:
140    if o.type != 'MESH' or not o.visible_get():
141        continue
142    e = o.evaluated_get(deps)
143    m = e.to_mesh()
144    m.calc_loop_triangles()
145    t = len(m.loop_triangles)
146    e.to_mesh_clear()
147    tris += t
148    objs.append([o.name, t])
149objs.sort(key=lambda x: -x[1])
150arms = [o for o in bpy.context.scene.objects if o.type == 'ARMATURE']
151result = {
152    "triangles": tris,
153    "objects": objs[:8],
154    "bones": max([len(a.data.bones) for a in arms] or [0]),
155    "materials": len([m for m in bpy.data.materials if m.users]),
156    "textures": [[i.name, i.size[0], i.size[1]] for i in bpy.data.images if i.users and i.size[0]],
157    "actions": [a.name for a in bpy.data.actions][:12],
158}
159`
160
161/** O dicionário `result` que o Blender MCP devolve em texto. */
162export const parseStats = (text: string): SceneStats | undefined => {
163  const start = text.indexOf('{')
164  const end = text.lastIndexOf('}')
165  if (start < 0 || end <= start) return undefined
166  try {
167    const raw = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
168    const data = (raw.result && typeof raw.result === 'object' ? raw.result : raw) as {
169      triangles?: number
170      objects?: [string, number][]
171      bones?: number
172      materials?: number
173      textures?: [string, number, number][]
174      actions?: string[]
175    }
176    if (typeof data.triangles !== 'number') return undefined
177    return {
178      triangles: data.triangles,
179      objects: (data.objects ?? []).map(([name, triangles]) => ({ name, triangles })),
180      bones: data.bones ?? 0,
181      materials: data.materials ?? 0,
182      textures: (data.textures ?? []).map(([name, width, height]) => ({ name, width, height })),
183      actions: data.actions ?? [],
184    }
185  } catch {
186    return undefined
187  }
188}
189
190/** O caminho do .glb num script de export do Blender. */
191export const exportPath = (code: string): string | undefined => {
192  if (!/export_scene\.gltf/.test(code)) return undefined
193  return /filepath\s*=\s*r?["']([^"']+\.glb)["']/i.exec(code)?.[1]
194}
195
types/index.d.ts 38 lines
1export type Budgets = { maxTriangles: number; maxBones: number; maxTextureSize: number }
2
3export type SceneStats = {
4  triangles: number
5  objects: { name: string; triangles: number }[]
6  bones: number
7  materials: number
8  textures: { name: string; width?: number; height?: number }[]
9  actions: string[]
10}
11
12export type GlbReport = {
13  sizeBytes: number
14  triangles: number
15  meshes: { name: string; triangles: number }[]
16  materials: number
17  textures: { name: string; width?: number; height?: number }[]
18  bones: number
19  animations: string[]
20}
21
22export type Preview = {
23  /** O PNG da miniatura, em base64. */
24  png?: string
25  stats?: SceneStats
26  at: number
27  /** Por que não deu para atualizar (Blender fechado, MCP desconectado...). */
28  problem?: string
29}
30
31export type GlbCheck = { path: string; report?: GlbReport; problem?: string; at: number }
32
33declare module 'claude-code' {
34  interface PluginState {
35    'blender-preview': { preview: Preview | null; glb: GlbCheck | null; busy: boolean }
36  }
37}
38