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

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.
<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.
| Mod | What it gives you |
|---|---|
| usage-limits | Your 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. |
| tarefas | The 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-tools | Compiler 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-preview | A 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.
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.
<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?
/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./economia on). The main thread keeps its model while new subagents run on Claude Haiku 5.5.| Option | Default | |
|---|---|---|
adminApiKey | — | sk-ant-admin… key of the Console organization that receives the plan's API credits. Kept in secure storage. |
apiMonthlyCredit | 200 | Max 5x: 100 · Max 20x: 200 |
apiCycleDay | 1 | Day of the month the credit renews |
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.autoHandoff is on, every turn that edits files refreshes the file list, so there's a record even if the session dies.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.
| Option | Default |
|---|---|
longTurnMinutes | 3 |
sound / speak | true / false |
autoHandoff | false (turn on to refresh the file after every turn that edits) |
handoffPath | .claude/handoff.md |
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).
/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.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./handoff carries the open tasks to the other machine.| Option | Default |
|---|---|
tasksPath | TASKS.md |
showBand | true |
claudeCanEdit | true |
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.
.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.Library/, Temp/, Logs/ or UserSettings/;.unity or .prefab files;.meta files by hand;UnityEngine inside an engine-free core folder.| Option | Default | |
|---|---|---|
projectPath | auto | Project folder, if auto-detection misses |
unityPath | Unity Hub path for the project's version | |
coreDir | Assets/Scripts/Core | Engine-free folder; empty turns the guard off |
compileCheck | true | |
allowSceneEdits | false | |
serverBuildPath | Builds/Server/Server.x86_64 | |
buildMethod | — | e.g. MyGame.Editor.BuildServer.Build; empty uses -buildLinux64Player |
Requirements:
dotnet), for the compile check.Works with Blender connected to Claude Code through a Blender MCP server.
/blender opens the pane..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.| Option | Default |
|---|---|
maxTriangles | 20000 |
maxBones | 100 |
maxTextureSize | 2048 |
autoPreview | true |
server | Blender |
Everything stays on your machine.
~/.claude/usage-limits/, to each plugin's own store, to .claude/handoff.md and TASKS.md in your project.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.unity-tools runs dotnet and the Unity editor; blender-preview talks to your local Blender through MCP.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.
MIT © Kevin Medeiros. Not affiliated with Anthropic.
hooks/register.tsx 220 lines1import { 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}
220hooks/glb.ts 195 lines1import 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}
195types/index.d.ts 38 lines1export 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