npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

jsblender

v0.0.4

Published

Modern, typesafe .blend file parser for Blender 5 and up.

Readme

jsblender

Modern, typesafe .blend file parser for Blender 5 and up. Works in Node, Bun, and the browser.

⚠️ Heavily vibe-coded. The code is not reviewed, will probably crash on edge-case .blend files, and the surface area exposed only covers what the author needed. Not recommended for anything important.

Status

  • Decompresses zstd (Blender 3.0+ default) and reads already-uncompressed .blend files.
  • Parses the modern 17-byte file header and the 32-byte block headers used by Blender 5.
  • Reconstructs the SDNA (Structure DNA) schema and exposes typed struct layouts.
  • Resolves Blender 5's reused-pointer quirk: the writer hands several ID datablocks the same oldPtr for their temporary buffers — jsblender disambiguates by file position.

Install

npm install jsblender
# or
bun add jsblender

ESM-only. Node 18+, Bun, or any modern browser. The only runtime dependency is fzstd (pure JS).

Quick start

import { readFileSync } from 'node:fs'
import {
  parseBlend,
  extractScenes,
  extractMeshes,
  extractMaterials,
  extractObjects,
  extractLights,
  extractCameras,
  extractImages,
  extractArmatures,
} from 'jsblender'

const blend = parseBlend(readFileSync('scene.blend'))

console.log(blend.header)
// { version: 5.01, pointerSize: 8, endianness: 'little', largeFormat: true, size: 17, versionString: '0501' }

for (const mesh of extractMeshes(blend)) {
  console.log(mesh.name, mesh.vertexCount, mesh.faceCount, mesh.triangles.length / 3)
  console.log('custom props:', mesh.customProperties)
}

In the browser, get the bytes from a drop / file picker / fetch:

const buf = new Uint8Array(await file.arrayBuffer())
const blend = parseBlend(buf)

API

parseBlend(input: Uint8Array | ArrayBuffer): BlendFileData

Decompresses (if needed), validates, and indexes the file. The returned object is what every extractor reads from:

interface BlendFileData {
  header: BlendHeader // version, pointer size, endianness, large-format flag
  sdna: SDNA // names, types, sizes, struct layouts
  blocks: BlendBlock[] // every block in file order, last entry is ENDB
  reader: StructReader // low-level cursor with pointer-resolution helpers
}

Errors thrown:

  • Not a .blend file: unrecognised magic bytes — input does not start with BLENDER, zstd, or gzip magic.
  • gzip-compressed .blend files are not supported in this runtime — legacy gzip-compressed file in a runtime without Bun.gunzipSync. Re-save the file in Blender 3+ (zstd) or decompress upfront.
  • No DNA1 block found in .blend file — file is truncated or corrupt.

extractMeshes(blend): Mesh[]

Returns one entry per ME datablock. Geometry comes from the new AttributeStorage (Blender 5) — positions, corner→vertex/edge indices, material indices, UV maps, vertex colour layers. Face data is presented in offset form (matching Blender's internal layout) and additionally triangulated for direct GPU upload.

interface Mesh {
  name: string
  vertexCount: number
  edgeCount: number
  faceCount: number
  cornerCount: number
  vertices: Float32Array // length = vertexCount * 3
  vertexNormals: Float32Array // recomputed from face winding
  faceNormals: Float32Array // length = faceCount * 3
  faceOffsets: Uint32Array // length = faceCount + 1; loops for face i live in [offsets[i], offsets[i+1])
  cornerVertices: Uint32Array // per-corner vertex index
  cornerEdges?: Uint32Array // per-corner edge index, when stored
  materialIndices: Uint32Array // length = faceCount; index into materialSlotNames
  materialSlotNames: string[]
  triangles: Uint32Array // triangulated face indices (fan from corner 0)
  uvMaps: Record<string, Float32Array> // CD_PROP_FLOAT2 on CORNER domain
  vertexColors: Record<string, Float32Array> // CD_PROP_COLOR (float4)
  vertexByteColors: Record<string, Uint8Array> // CD_PROP_BYTE_COLOR (uchar4)
  vertexGroupNames: string[]
  dvert?: DeformVertex[] // per-vertex weights, when the mesh has any
  attributes: Record<string, MeshAttributeRaw> // every attribute, including ones not surfaced above
}

interface DeformVertex {
  totalWeight: number
  weights: { groupIndex: number; weight: number }[]
}

Every returned datablock also carries customProperties: Record<string, IDPropertyValue> and a sibling customPropertyTypes: Record<string, IDPropertyTypeName> — see Custom properties below.

extractMaterials(blend): Material[]

interface Material {
  name: string
  diffuse: [r, g, b, a] // float, linear
  specular: [r, g, b]
  metallic: number
  roughness: number
  hasNodeTree: boolean
  shader?: ShaderGraph // present when nodes are used
  customProperties: Record<string, IDPropertyValue>
}

interface ShaderGraph {
  nodes: ShaderNode[]
  principled?: PrincipledBSDF // distilled, when the graph has one
}

interface PrincipledBSDF {
  nodeName: string
  baseColor: [r, g, b, a]
  metallic: number
  roughness: number
  ior: number
  alpha: number
  emissionColor: [r, g, b, a]
  emissionStrength: number
  baseColorImage?: string // name of an image bound via a Tex Image node
  normalImage?: string
  roughnessImage?: string
  metallicImage?: string
}

The full node graph is exposed in shader.nodes — each node lists its idname (ShaderNodeBsdfPrincipled, ShaderNodeTexImage, …), its inputs with their default-value sockets, and any incoming links. Anything beyond Principled BSDF you can pull yourself from there.

extractLights(blend): Light[]

interface Light {
  name: string
  type: 'point' | 'sun' | 'spot' | 'area'
  color: [r, g, b]
  energy: number
  radius: number
  spotSize?: number // spot only
  spotBlend?: number
  sunAngle?: number // sun only
  areaShape?: 'square' | 'rectangle' | 'disk' | 'ellipse'
  areaSize?: [x] | [x, y]
  useNodes: boolean
  customProperties: Record<string, IDPropertyValue>
}

extractCameras(blend): Camera[]

interface Camera {
  name: string
  type: 'perspective' | 'orthographic' | 'panoramic'
  lens: number // mm
  sensorWidth: number
  sensorHeight: number
  sensorFit: 'auto' | 'horizontal' | 'vertical'
  orthoScale: number
  clipStart: number
  clipEnd: number
  shiftX: number
  shiftY: number
  customProperties: Record<string, IDPropertyValue>
}

extractImages(blend): Image[]

interface Image {
  name: string
  filepath: string // e.g. "//tex.png" (relative) or an absolute path
  source: 'file' | 'sequence' | 'movie' | 'generated' | 'viewer' | 'tiled'
  generatedWidth: number
  generatedHeight: number
  packed?: Uint8Array // raw bytes of the embedded file, when packed
  customProperties: Record<string, IDPropertyValue>
}

extractScenes(blend): Scene[] and extractCollections(blend): Collection[]

interface Scene {
  name: string
  cameraObject?: string // active camera object name
  frameStart: number
  frameEnd: number
  frameCurrent: number
  fps: number // frs_sec / frs_sec_base
  resolutionX: number
  resolutionY: number
  resolutionPercentage: number
  rootCollection?: Collection
  customProperties: Record<string, IDPropertyValue>
}

interface Collection {
  name: string
  objectNames: string[] // objects directly inside this collection
  children: Collection[] // recursive
  customProperties: Record<string, IDPropertyValue>
}

extractScenes returns scenes with their full collection hierarchy. extractCollections returns every standalone GR datablock at the top level, also walkable.

extractObjects(blend): SceneObject[]

Every OB datablock — cameras, lamps, meshes, armatures, etc.

interface SceneObject {
  name: string
  type: number // see OB_TYPE
  location: [x, y, z]
  rotation: [x, y, z] // Euler XYZ, radians
  scale: [x, y, z]
  worldMatrix: Float32Array // float[16], row-major
  dataName?: string // name of the linked ID datablock (e.g. the mesh)
  parentName?: string
  customProperties: Record<string, IDPropertyValue>
}

import { OB_TYPE } from 'jsblender'
// OB_TYPE.EMPTY = 0, MESH = 1, CURVE = 2, SURF = 3, FONT = 4, MBALL = 5,
// LAMP = 10, CAMERA = 11, SPEAKER = 12, LIGHTPROBE = 13,
// LATTICE = 22, ARMATURE = 25, GPENCIL = 26

extractArmatures(blend): Armature[]

interface Armature {
  name: string
  bones: Bone[] // top-level (root) bones
  customProperties: Record<string, IDPropertyValue>
}

interface Bone {
  name: string
  head: [x, y, z] // armature-space
  tail: [x, y, z] // armature-space
  roll: number // radians
  length: number
  armatureMatrix: Float32Array // 4x4 rest pose, row-major
  children: Bone[]
}

Modifiers (evaluateMesh)

extractMeshes returns each ME datablock as stored — no Subdiv, Mirror, Array, etc. evaluation. For renderers that need the visible geometry, jsblender ships a small modifier evaluator that supports Mirror and Array (the two cheapest geometry-generating modifiers).

import { evaluateMesh, evaluateAllMeshes, extractObjectModifiers } from 'jsblender'

const obj = extractObjects(blend).find(o => o.name === 'megaxe')!
const mesh = evaluateMesh(blend, obj) // Mesh with Mirror / Array applied

// Or batch:
const byObject = evaluateAllMeshes(blend) // Map<objectName, Mesh>

// Inspect a stack without evaluating:
const mods = extractObjectModifiers(blend).get('megaxe')
// [{ type: 'mirror', axisX: true, merge: true, tolerance: 0.001, ... },
//  { type: 'array',  count: 4,    useRelativeOffset: true, ... }]

Supported modifiers:

| Modifier | What jsblender does | Skipped | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | Mirror | Per-axis vertex duplication with reversed face winding. Spatial weld merges mirrored positions onto existing vertices within tolerance (handles seams-on-plane and stacked redundant mirrors). | mirror_ob frame, MOD_MIR_BISECT_*, UV swapping, vertex-group swap. | | Array | Fixed-count duplication with combined constantOffset + relativeOffset × bbox. | FIT_LENGTH / FIT_CURVE, useObjectOffset, merge-between-copies, start/end caps. |

Other modifier types are returned as { type: 'unknown', typeCode } and pass the mesh through unchanged.

Custom properties (IDProperty)

Every datablock returned by an extract* function carries a customProperties: Record<string, IDPropertyValue> field — Blender's user-defined metadata, decoded into plain JS values.

type IDPropertyValue =
  | string
  | number
  | boolean
  | number[] // INT / FLOAT / DOUBLE array
  | boolean[] // BOOLEAN array
  | { [key: string]: IDPropertyValue } // GROUP (nested object)
  | { __idRef: string | null } // ID reference (datablock by name)
  | IDPropertyValue[] // IDPARRAY

extract* functions surface only the user's ID.properties group; Blender's internal system_properties are ignored.

const cube = extractMeshes(blend).find(m => m.name === 'Cube')
cube?.customProperties
// {
//   myFloat: 1,
//   myInteger: 1,
//   myBoolean: true,
//   myString: 'abc',
//   myFloatArray: [1, 1, 1],
//   myDataBlock: { __idRef: null },
// }

INT and FLOAT collapse onto the same JS number, which matters when you want to serialise back to a format that distinguishes them (e.g. emitting an INT 5 vs a FLOAT 5.0). Each datablock also carries a sibling customPropertyTypes: Record<string, IDPropertyTypeName> keyed by the same names, holding the original IDP_TYPE:

cube?.customPropertyTypes
// {
//   myFloat: 'DOUBLE',
//   myInteger: 'INT',
//   myBoolean: 'BOOLEAN',
//   myString: 'STRING',
//   myFloatArray: 'ARRAY',
//   myDataBlock: 'ID',
// }

IDPropertyTypeName mirrors the keys of the exported IDP_TYPE constant — 'INT' | 'FLOAT' | 'DOUBLE' | 'BOOLEAN' | 'STRING' | 'ARRAY' | 'GROUP' | 'ID' | 'IDPARRAY'. Nested groups show up as 'GROUP'; their inner types aren't recursed into. For one-off lookups you can also call readCustomPropertyTypes(reader, idOffset) directly.

Low-level access

If the typed extractors above don't cover what you need, drop down to the SDNA-driven reader. Every struct Blender exports is queryable by name, every field by name:

const { reader, sdna } = blend

const meshLayout = reader.layoutOf('Mesh') // SDNAStructLayout
const totvert = reader.fieldOf(meshLayout, 'totvert')

for (const block of blend.blocks) {
  if (block.code !== 'ME') continue
  const count = reader.readInt32(block.dataOffset + totvert.offset)
  console.log('mesh has', count, 'vertices')
}

Pointer fields point at other blocks. reader.blockAt(ptr, anchor?) resolves them, using anchor (the dereferencing block's dataOffset) as a tie-breaker when Blender reuses oldPtr values across writes:

const fMat = reader.fieldOf(meshLayout, 'mat')
const matsPtr = reader.readPointer(meshBlock.dataOffset + fMat.offset)
const matsBlock = reader.blockAt(matsPtr, meshBlock.dataOffset)

The full SDNA is exposed too:

sdna.types // string[]    every type name in the file
sdna.typeSizes // number[]    byte size of each type
sdna.names // string[]    every field name (raw form, e.g. "*next", "head[3]")
sdna.parsedNames // ParsedFieldName[]  same names decomposed
sdna.structs // SDNAStruct[] type-index + fields
sdna.layouts // SDNAStructLayout[] precomputed offsets and sizes
sdna.structIndexByType // Map<string, number>

For the modern attribute-storage layout there are additional helpers:

import { ATTR_TYPE, ATTR_DOMAIN, readAttributeStorage, readAttributeAsFloats } from 'jsblender'

const mesh = reader.layoutOf('Mesh')
const fStorage = reader.fieldOf(mesh, 'attribute_storage')
const attrs = readAttributeStorage(reader, meshBlock.dataOffset, fStorage)
const pos = attrs.find(a => a.name === 'position' && a.dataType === ATTR_TYPE.FLOAT3)
const positions = pos ? readAttributeAsFloats(reader, pos) : undefined

Runtime support

  • Node 18+ — works directly with the published ESM build. Verified on Node 25.
  • Bun — primary development target. The test suite runs under bun test.
  • Browsers — see example/ in this repo for a Next.js drop-zone that parses files entirely client-side.

The library makes no node:* imports, so bundlers (Vite, esbuild, Webpack, Next, etc.) target browsers without polyfills.

Caveats

  • Only Blender 5+ files have been validated. Legacy 12-byte headers are recognised, but the per-mesh layout assumes the modern AttributeStorage pipeline; pre-3.0 files are out of scope.
  • Big-endian and 32-bit pointer code paths exist in the block walker but have no real-world test coverage.
  • Vertex normals are recomputed from face winding because Blender no longer writes them by default. For custom split normals, read the corner_normal attribute via mesh.attributes.
  • Shader node trees, modifiers, animations, and the bGPencil format are not parsed.

Development

This is a Bun monorepo with two workspaces:

library/   the jsblender package
example/   Next.js drop-zone demo
bun install
bun run --filter library build   # tsup -> library/dist
bun run --filter library test    # bun test against simple.blend
bun run --filter example dev     # Next.js dev server on portless

bun run all                      # format:check + lint + typecheck + warden + test