OpenSKP
docs
Web Viewer GitHub Report Issue
OpenSKP Documentation
The open-source SketchUp (.skp) file toolkit — parse, write, and convert .skp files, natively in Python, TypeScript, .NET, Dart, and C++. Convert SketchUp models to glTF (GLB), OBJ, STL, PLY, DXF, IFC4, and JSON with no SketchUp SDK, no license, both the modern VFF (2021+) and classic MFC (2013–2020) formats supported. This page is the detailed, verified developer reference; the full write-ups live in docs/ on GitHub.
5
Languages
7
Convert-to formats
1,306
Tests passing
620MB
Largest file verified (.NET)
2
Container formats (VFF + legacy MFC)
Installation
Pick your language — the API is equivalent across all five
bash
pip install openskp
python
from openskp import SkpFile

model = SkpFile.open("my_model.skp").parse()
print(model.version, len(model.layers))

# Opt-in: full placed scene graph, triangulated, world-space, GLB-ready
scene = SkpFile.open("my_model.skp").build_scene()
print(len(scene.glb_primitives), "mesh primitives")
bash
npm install openskp
typescript
import { SkpFile, toGLB } from 'openskp'

// Node.js
const model = SkpFile.open('my_model.skp').parse()

// Browser - same package, isomorphic
// const buffer = await fetch('my_model.skp').then(r => r.arrayBuffer())
// const model = parseSkp(buffer)

const scene = SkpFile.open('my_model.skp').buildScene()
const glb = toGLB(scene)   // ready to write to a .glb file
bash
dotnet add package OpenSkp
csharp
using OpenSkp;

var model = SkpFile.Open("my_model.skp");
Console.WriteLine($"{model.Version} - {model.Layers.Count} layers");

var scene = SkpFile.BuildScene("my_model.skp");
Console.WriteLine($"{scene.GlbPrimitives.Count} mesh primitives");
bash
dart pub add openskp
dart
import 'package:openskp/openskp.dart';

final model = SkpFile.open('my_model.skp').parse();
print('${model.version} - ${model.layers.length} layers');

final scene = SkpFile.open('my_model.skp').buildScene();
print('${scene.glbPrimitives.length} mesh primitives');
cmake
find_package(OpenSkp CONFIG REQUIRED)
target_link_libraries(your_target PRIVATE OpenSkp::OpenSkp)
cpp
#include <openskp/openskp.hpp>

auto skp = openskp::SkpFile::open("my_model.skp");
auto model = skp.parse();

auto scene = skp.build_scene();
auto glb = openskp::to_glb(scene);
openskp::export_glb(scene, "my_model.glb");
std::cout << scene.glb_primitives.size() << " mesh primitives\n";
No package registry today - build/install from source (or download a release tarball) via CMake, then find_package(OpenSkp CONFIG REQUIRED). See GitHub Releases for tagged source packages.
Core Concepts
Two entry points, and why they're separate
parse()
Light, default
Reads each component/group definition's geometry exactly once — vertices, edges, faces, and the un-resolved instance placements (which definition, what transform). No scene-graph walking, no triangulation. This is what you want for metadata inspection, custom geometry processing, or anything that doesn't need a renderable mesh.
buildScene()
Opt-in, heavier
Walks the entire placed scene graph: every instance of every component, nested arbitrarily deep, each with its transform resolved to world space, each face triangulated (a ported earcut — the same algorithm in all five languages) and grouped by resolved color into GLB-ready mesh primitives. For a file that reuses a handful of definitions across many thousands of placements, this can produce far more data than the file's raw geometry — that's why it's a separate, opt-in call.
Calling both parse() and buildScene() re-parses the raw TLV data twice — a deliberate trade of extra CPU time for guaranteeing parse() alone never pays for scene-baking's cost. They're independent, not layered.
Creating Files
create() — build a new .skp file from nothing, all five languages
OpenSKP can also go the other direction: create() returns an SkpBuilder that assembles a genuine legacy MFC CArchive-format .skp file (SketchUp 2013–2020) from nothing — geometry, materials, layers, component definitions, and groups — with no SketchUp SDK involved at import, build, or save time. It works by inverting this project's own reader logic against a small bundled blank-document scaffold.
Every feature on this page has been validated feature-by-feature against the real SketchUp SDK (SketchUpAPI.dll), not just against this project's own reader — and it holds up rebuilding complex, real architectural models, not only synthetic test fixtures.
Landed in Python first, then ported to TypeScript, .NET, Dart, and C++ — the identical feature set is now available in all five languages (1.1.0). Combined with the export formats below, this makes OpenSKP a genuine SketchUp file converter in both directions this project targets: build a .skp from nothing, or convert an existing one to glTF/OBJ/STL/PLY/DXF/IFC4/JSON.
Materials, layers & geometry
Solid-color and PNG/JPEG-textured materials, and named layers with their own color and default visibility — reused by name across every face, instance, and group you place.
python
from openskp import create

builder = create()
red = builder.add_material("Red", (255, 0, 0))
brick = builder.add_texture_material("Brick", "brick.png")
roof = builder.add_layer("Roof", color=(180, 60, 40))

builder.add_face(
    [(0, 0, 0), (200, 0, 0), (200, 150, 0), (0, 150, 0)],
    material=red, layer=roof,
)
builder.save("output.skp")
Definitions, instances & groups
Reusable component definitions with multiple positioned instances, one-off groups, and nesting to any depth — a definition can place instances or groups of its own already-built sub-parts, the same way a real SketchUp assembly does.
python
with builder.add_component_definition("Wheel") as wheel:
    wheel.add_face([(0, 0, 0), (10, 0, 0), (10, 10, 0), (0, 10, 0)])

with builder.add_component_definition("Car") as car:
    car.add_instance(wheel, translation=(0, 0, 0))
    car.add_instance(wheel, translation=(100, 0, 0))

builder.add_instance(car, translation=(0, 0, 0))
with builder.add_group("Table", translation=(200, 0, 0)) as table:
    table.add_face([(0, 0, 0), (60, 0, 0), (60, 40, 0), (0, 40, 0)])
One ordering rule falls out of the format's own slot numbering: every add_component_definition/add_group call must happen before any add_face/add_instance call on the builder itself.
Rotation & visibility
rotation=(axis, angle_radians) is a shortcut for a hand-derived matrix3x3, and every placement — instance or group — can be created hidden=True (its contents still exist in the file, just not shown by default).
python
import math

builder.add_instance(wheel, translation=(0, 0, 0), rotation=((0, 0, 1), math.radians(90)))
builder.add_instance(wheel, translation=(100, 0, 0), hidden=True)
Curved geometry
add_circle/add_arc write a genuine, editable-by-radius CArcCurve entity — not disconnected straight edges that merely trace the shape. add_polyline groups an arbitrary edge chain into one real CCurve entity, the same grouping SketchUp's own Freehand tool produces.
python
builder.add_circle((100, 75, 0), normal=(0, 0, 1), radius=30, num_segments=24)
builder.add_arc((100, 75, 0), normal=(0, 0, 1), radius=30, start_angle=0, end_angle=math.pi / 2)
builder.add_polyline([(0, 0, 0), (10, 10, 0), (20, 0, 0), (30, 10, 0)])
Faces: holes & non-planar input
A face can have one or more holes cut out of it (a window opening in a wall) via holes=, and auto_triangulate=True fan-triangulates non-planar input instead of raising — the same silent fallback real SketchUp's own UI applies to a not-quite-flat quad.
python
wall = [(0, 0, 0), (200, 0, 0), (200, 100, 0), (0, 100, 0)]
window = [(80, 30, 0), (120, 30, 0), (120, 70, 0), (80, 70, 0)]
builder.add_face(wall, holes=[window])

warped_quad = [(0, 0, 0), (10, 0, 0), (10, 10, 0), (0, 10, 5)]
builder.add_face(warped_quad, auto_triangulate=True)  # -> 2 triangular faces
Texture positioning & custom attributes
A face's texture can be explicitly positioned (scaled, rotated, sheared, offset — independently per side) via 3 world-point/UV correspondences, on a face of any orientation. Component definitions, instances, and faces can also carry custom key/value metadata — the same mechanism SketchUp's own "dynamic component" attributes use.
python
builder.add_face(
    [(0, 0, 0), (100, 0, 0), (100, 100, 0), (0, 100, 0)],
    material=brick,
    front_uv=[((0, 0, 0), (0.0, 0.0)), ((50, 0, 0), (1.0, 0.0)), ((0, 50, 0), (0.0, 1.0))],
)

with builder.add_component_definition("Chair", attributes={"sku": "CH-100", "price": 49.99}) as chair:
    chair.add_face([(0, 0, 0), (20, 0, 0), (20, 20, 0), (0, 20, 0)])
Scope at a glance
FeatureStatus
Solid + PNG/JPEG-textured materials✓
Layers with color & default visibility✓
Component definitions, multi-instance, groups, nesting✓
Instance/group rotation & hidden state✓
Circular/arc curves & freeform polylines✓
Faces with holes & auto-triangulation✓
Explicit per-side texture positioning✓
Custom attributes on definitions/instances/faces✓
Attributes on groups✗ not yet
Modern VFF (2021+) output✗ legacy format only
See openskp/create.py for the full, current scope notes.
Editing Existing Files
openskp.open_existing() — load a file that already exists, and extend it
openskp.create() only ever starts from its own blank scaffold. Real SketchUp never patches a file in place either — it fully re-serializes the whole document on every save — so there's no stable byte region to append to for an arbitrary existing file. open_existing() takes the same approach real SketchUp effectively does: parse → replay → extend → save. It fully parses the source file with this project's own reader, then replays everything it understood — materials, layers, every component definition, all root-level geometry and instances — back through the writer's own public API, producing a brand-new file with equivalent content that more geometry can still be added to.
python
from openskp import open_existing

builder, warnings, definitions = open_existing("building.skp")
for w in warnings:
    print("not fully reproduced:", w)

# Every material/layer the source had is already reusable, no separate lookup:
roof = builder.materials_by_name.get("Roofing")
builder.add_circle((0, 0, 100), (0, 0, 1), radius=50, material=roof)

# definitions maps each replayed component's own name to its builder:
builder.add_instance(definitions["Window"], translation=(0, 300, 0))
builder.save("building_edited.skp")
What can and can't be added afterward
The returned builder is ready for more add_face/add_circle/add_instance/etc. calls, reusing every material and layer the source already had. What it can no longer do is register a genuinely new material, layer, or component definition/group — by the time replay finishes writing the source's own root-level geometry, this writer's usual file-format ordering requirement (materials/layers/definitions must be finalized before any geometry) is already satisfied.
add_material, add_layer, add_component_definition, and add_group all raise on a builder returned by open_existing() — build anything genuinely new into a separate create() call instead.
Known fidelity gaps
The returned warnings list is the honest, per-file account of what couldn't be faithfully reproduced. Only a legacy-format (2013–2020) source is accepted, for the same reason the writer only ever produces that format. Round-trip-validated against real, non-writer-authored architectural models, not just files this project's own writer produced.
GapDetail
Per-edge flagshidden/soft/smooth are applied per-face, not per-edge — an "any edge in this boundary has the flag" approximation
Projective texturespositioned textures replay via a 3-point affine fit — exact at those points, but a genuinely projective/distorted source mapping won't interpolate identically. A draped/projected texture falls back to the default projection
Colorized tinta colorized (tinted) material replays as its plain source texture, losing the tint — its real-world texture tile size is preserved, via an explicit UV pin on every replayed textured face
Per-face layer paintingonly a face's front/back material is replayed — the reader doesn't expose a per-face layer assignment
Group vs. instanceevery placed thing replays as a plain component instance — visually identical, but no longer shows as a "Group" in SketchUp's Outliner
Section planes, text, dimensionsnot carried over at all — the writer has no support for these entity types
Curve groupinga circle/arc/polyline's original curve grouping is lost — the reader doesn't preserve it, so it round-trips as a plain straight-edged face
Definition/face attributesnot reproduced — the reader's public model doesn't expose either (only an instance's own properties are)
See openskp/edit.py's own module docstring for the complete, itemized list and the reasoning behind each one.
Generating Code From a File
openskp.to_python_code() — turn a parsed file into re-runnable source, not a builder
Every language has an equivalent — to_python_code(), toTypeScriptCode(), Codegen.ToCSharpCode(), toDartCode(), to_cpp_code(). It takes the opposite approach from open_existing(): instead of a builder you keep editing programmatically, it returns a string of source code — a faithful, human-readable, re-runnable transcript of create()/SkpBuilder calls that rebuilds an equivalent file when run. Materials (solid and textured, with explicit UV pins), layers, component/group definitions in dependency order, faces with holes, and instance-level paint and names (including a genuinely empty name) are all reproduced.
python
from openskp import SkpFile, to_python_code

model = SkpFile.open("building.skp").parse()
print(to_python_code(model))
generated output
import base64
import os
import tempfile

from openskp import create


def build():
    builder = create()

    # --- Materials (1) ---
    mat0 = builder.add_material('Red', (255, 0, 0, 255))

    # --- Layers (2) ---
    layer0 = builder.add_layer('Layer0', color=(255, 84, 84), hidden=False)
    layer1 = builder.add_layer('Roof', color=(200, 60, 60), hidden=False)

    # 'Wheel' - 1 faces, 0 nested instances
    with builder.add_component_definition('Wheel') as def0:
        def0.add_face([(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], material=mat0, auto_triangulate=True)

    # --- Root instances (1) ---
    builder.add_instance(def0, translation=(50.0, 0.0, 0.0), matrix3x3=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0), material=mat0, name='')

    return builder.to_bytes()
Why generate code instead of editing directly
Useful anywhere a file's structure is more valuable as editable code than as an opaque binary — handing a real model to an AI coding agent as a starting point it can read and modify without executing anything first, generating a diffable, reviewable text representation of a .skp file for version control, or understanding how a specific file was built without a SketchUp license. It's not a replacement for open_existing()'s programmatic editing — the returned string still needs to be executed to produce a file — and shares every fidelity gap listed above, since both reuse the same UV/hole/instance-paint reconstruction logic.
Every textured face gets an explicit UV pin in the generated output — even ones that originally used default projection — so the regenerated material's applied height never needs to match the source's.
AI-Generated Models
Why the writer above works well as an AI coding-agent target
The writer's API is generic on purpose - no add_chair() or add_staircase() helpers, just materials, faces, components, and instances. That turns out to make it an unusually good target for AI coding agents: no object-specific primitives library is needed for an agent to compose arbitrary shapes, since it already knows how to turn a description into geometry once the API is in context.
Every model below was generated from a natural-language (or reference-photo) prompt, by two independent AI agents, using nothing but the raw create() API documented on this page - no primitives library, no hand-authored geometry.
AI-generated armchair and side table
Armchair + side table - tapered legs, curved tessellated backrest
AI-generated executive desk with drawers
Executive desk - 8 nested component definitions
AI-generated smartphone modeled from a reference photo
Smartphone - modeled directly from a product reference photo
Two more real models - a mid-century dining chair + accent table (9 component definitions, 38 meshes, 4 materials) and a small gable cottage (6 materials including translucent glass) - were generated the same way but don't have a saved render yet; both are documented with their exact stats and a real code excerpt in the full write-up below.
This isn't a roadmap item - it works today. Point your AI coding agent at this page (or any package README's Writing section) and a concrete goal in plain language, and it can write and run real create() code against the same API documented above. See docs/AI_MODELING.md on GitHub for the full write-up, including a real code excerpt and open directions for contributors.
Data Model
Structurally equivalent output across all five languages
All five languages produce the same shape for the same file — cross-validated directly against each other on real fixtures, not just against each language's own idea of what the format means. Coordinates are inches, Z-up (SketchUp's native units) in parse()'s result; buildScene()'s output converts to meters, Y-up (glTF convention).
ConceptPythonTypeScript.NETDartC++
definitionsdict[int, Definition]Map<number, Definition>Dictionary<long, Definition>Map<int, Definition>std::map<EntityId, Definition>
Vertexid, x, y, z{id, x, y, z}Id, X, Y, Zid, x, y, zid, x, y, z
Edgeid, v1_id, v2_id, soft, smooth, hiddencamelCasePascalCasecamelCasesnake_case (matches Python)
Faceid, loops, normal, material_id, back_material_id, uv_transformcamelCasePascalCasecamelCasesnake_case (matches Python)
Layername, color_r, color_g, color_bname, color:{r,g,b}Name, ColorR/G/Bname, colorR/G/Bname, color (std::array<uint8_t,3>)
The root definition
Every .skp file has an implicit top-level definition — geometry drawn directly in the model (not inside any component/group) and the top-level placed instances. How each language exposes it is not currently uniform — see Known Differences before assuming one language's shape applies to another's.
Legacy Format Support
SketchUp 2013–2020, classic MFC container
SketchUp 2021 switched .skp's container from a classic MFC CArchive object-graph serialization (versions 8 through 2020, internally versions 13–20) to the modern VFF/ZIP container. OpenSKP reads both, transparently — SkpFile.open()/.parse() auto-detects which era a file uses by its header bytes and routes to the matching walker. There is no separate API to call for old files, and the resulting SkpModel/Scene shape is identical either way.
The legacy walker was reverse-engineered independently of the public "2017 format notes" — several details (edge/loop record ordering, entity preamble structure, per-version byte-count differences between v16 and v17+) were established by clean-room analysis and cross-validated against the same models re-saved as VFF, matching face/edge counts, surface area, and bounding boxes exactly.
Legacy files cost more CPU per byte than modern VFF files (the MFC object-graph format requires resolving a shared, order-dependent slot table rather than a self-describing TLV tree) — but the same lazy, streaming architecture applies. See Performance.
Performance & Memory
The architecture change that made large real files actually work
The memory architecture
Real production .skp files can have well over 100,000 separate component definitions. The naive approach — parse the entire file into one in-memory tree, then walk it — means peak memory scales with the whole file's node count, which is what made large files crash outright before this was fixed.
All five languages now parse one top-level record at a time: a cheap flat header scan (O(sibling count), not O(total node count)) finds each top-level definition/layer-manager/material-manager/root block, fully builds only that one record's subtree, hands it to the caller, and lets it be garbage-collected before the next one is built. Peak memory during the walk is bounded by the size of the single largest top-level record, not the file's total size. The per-tag extraction logic was untouched — only the orchestration loop changed.
.NET's additional fix: the CLR's array and MemoryStream types have a hard ~2.1GB ceiling regardless of GC settings, and a decompressed model.dat can exceed that (SketchUp's format commonly compresses at ~10x). This needed a genuine architecture addition: ChunkedBuffer (a multi-segment byte buffer) plus widening every TLV offset from int to long. As a result, .NET has no practical file-size ceiling today — verified against a 620MB real file (153,586 definitions) with zero special configuration.
Verified numbers (real files)
LanguageFile sizeDefinitionsConfig neededTime
.NET620 MB153,586none~230–270s parse, ~17s scene build
Python294 MB336,254none~400s
Dart294 MB336,253--old_gen_heap_size=4096~82s
TypeScript18.5 MB1,264none~4s
TypeScript113 MB132,879--max-old-space-size=16384~34s
TypeScript294 MB336,254—fails even at 16GB heap
Python and .NET need no configuration regardless of file size in the files tested. Dart and TypeScript run on their own VM/engine heap, which defaults to a few GB — for files past roughly 50–100MB, raise it:
bash
# Dart
DART_VM_OPTIONS="--old_gen_heap_size=4096" dart run your_script.dart

# Node.js
node --max-old-space-size=8192 your-script.js
TypeScript's ceiling is a real, currently open limitation — not just "needs a bigger flag." A 113MB file needed somewhere between 8GB and 16GB of heap, and a 294MB file failed even at 16GB. This is very likely V8's per-object memory overhead on the millions of individual small {id,x,y,z}-shaped objects a large file's vertices/edges/faces become. A browser tab has no equivalent of --max-old-space-size — the practical ceiling there is lower still (confirmed directly: a 113MB file hangs a browser tab outright). Practical guidance: TypeScript is solid up to the tens-of-MB / low-hundreds-of-thousands-of-definitions range; for larger files, prefer Python or .NET, or process server-side rather than in a browser tab.
Observability
Opt-in progress reporting and structured, location-carrying errors — silent by default
Every port exposes the same two things about a parse or scene-bake in progress: progress (how far through the file the walk has gotten) and structured errors (exactly where a failure happened, if one does). Neither is on by default — OpenSKP never prints or logs anything unless you ask, and you wire it into whatever logging/monitoring your own application already uses.
StageMeaning
headerFile doesn't start with the VFF magic marker — not a .skp file, or unrecognized format
zip_extractValid header but no embedded ZIP found, or no model.dat entry (VFF path only)
tlv_walkFailure walking model.dat's top-level records (modern VFF path). Carries recordIndex/totalRecords/tag
legacy_walkFailure walking the classic MFC CArchive object stream
legacy_defsFailure converting walked legacy objects into definitions. Carries definitionId
build_sceneFailure baking placed instances into a scene — almost always a triangulation failure. Carries definitionId
Per-language mechanism
Python uses the standard logging module (logging.getLogger("openskp")) — progress is reported as DEBUG-level log records rather than a second parallel mechanism, matching Python's ecosystem convention. TypeScript/.NET/Dart don't have an equivalent stdlib-blessed logging façade, so they use an explicit options object with onProgress/onLog callbacks (IProgress<T>-based in .NET).
python
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("openskp").setLevel(logging.DEBUG)
model = SkpFile.open("model.skp").parse()   # now logs progress/stages
typescript
const model = SkpFile.open("model.skp").parse({
  onProgress: (info) => console.log(`${info.stage}: ${info.current}/${info.total}`),
  onLog: (level, message) => console.log(`[${level}] ${message}`),
})
csharp
var options = new SkpParseOptions {
    Progress = new Progress<SkpParseProgress>(p => Console.WriteLine($"{p.Stage}: {p.Current}/{p.Total}")),
    OnLog = (level, msg) => Console.WriteLine($"[{level}] {msg}"),
};
var model = SkpFile.Open("model.skp", options);
dart
final model = SkpFile.open("model.skp").parse(ParseOptions(
  onProgress: (info) => print('${info.stage}: ${info.current}/${info.total}'),
  onLog: (level, message) => print('[$level] $message'),
));
Progress fires every 500 units (records/definitions/instances), plus once more at the very last unit — coarse enough to cost nothing on 100,000+ definition files, frequent enough to catch a stuck pipeline well before a human gives up waiting. See the full Observability guide on GitHub for the complete field reference and design rationale.
Error Handling
Structured, location-carrying — never a bare string
SkpParseError / SkpParseException
Every failure anywhere in the parse or scene-build path raises/throws this structured type. The original error is always preserved — Python's __cause__ (via raise ... from exc), TypeScript's .cause, .NET's .InnerException, Dart's .cause — so adding location context never means losing the stack trace that actually explains the bug.
FieldSet forMeaning
stagealwaysOne of the six stages above
recordIndex / totalRecordstlv_walk0-based index / total count — "N of M" position
tagtlv_walkThe TLV tag hex string (e.g. "7C15") being processed
definitionIdlegacy_defs, build_sceneThe definition being built when the failure happened
python
from openskp import SkpFile, SkpParseError

try:
    model = SkpFile.open("model.skp").parse()
except SkpParseError as e:
    print(f"parse failed: {e}")   # includes stage=... record=.../... etc.
    print(f"caused by: {e.__cause__}")
Convert & Export Capabilities
Convert a .skp file to 7 formats, natively, in all five languages
This is where OpenSKP becomes a real SketchUp file converter, not just a reader: buildScene()'s result (Scene, GlbPrimitive[], gltfMaterials) is already exactly the data a converter needs — triangulated, world-space, grouped by material — and every language ships native, from-scratch writers on top of it for every format below. No third-party CAD/BIM SDK is involved for any of them.
FormatExtensionShips in
glTF (binary GLB).glb✓ all 5 languages
Wavefront OBJ + MTL.obj✓ all 5 languages
STL (3D printing).stl✓ all 5 languages
PLY (Stanford Triangle Format).ply✓ all 5 languages
DXF 3D (AutoCAD Polyface Mesh / 3DFACE).dxf✓ all 5 languages
IFC4 (BIM / ISO 10303-21 STEP).ifc✓ all 5 languages
Full metadata JSON.json✓ all 5 languages
Python's converters, in openskp.export:
from openskp import SkpFile
from openskp.export import glb, obj, stl, ply, dxf, ifc, json_export

skp = SkpFile.open("model.skp")
model = skp.parse()
scene = skp.build_scene()

glb.export(skp, "output.glb")
obj.export(scene, "output.obj")
stl.export(scene, "output.stl")
ply.export(scene, "output.ply")
dxf.export(scene, "output.dxf")
ifc.export(scene, "output.ifc")
json_export.export(model, "output.json", scene=scene)
TypeScript: toGLB/toOBJ/toSTLAscii/toSTLBinary/toPLYAscii/toPLYBinary/toDXF/toIFC/toJSON, plus Node-only exportOBJ/exportSTL/exportPLY/exportDXF/exportIFC file writers. .NET: GlbExport/ObjExport/StlExport/PlyExport/DxfExport/IfcExport, each with a .Export* file-writing method, plus JsonExport.ToDict (in-memory only — no file-writing method). Dart: toGlb/exportGlb/toObj/exportObj/toStlAscii/toStlBinary/exportStl/toPlyAscii/toPlyBinary/exportPly/toDxf/exportDxf/toIfc/exportIfc/toJson. C++: to_glb/export_glb/to_obj/export_obj/to_stl_ascii/to_stl_binary/export_stl/to_ply_ascii/to_ply_binary/export_ply/to_dxf/export_dxf/to_ifc/export_ifc/to_json/export_json. TinyGLTF and miniz are private to OpenSKP's C++ package, not consumer dependencies.
The DXF converter specifically is verified against real desktop AutoCAD, not just lenient DXF readers — see the Changelog for the exact compatibility issues that surfaced and were fixed.
IFC export: options and real flexibility (Python)
The IFC exporter's real signature — every default is safe to leave alone, but each is a genuine lever, not decoration:
ifc.export(
    scene,
    "output.ifc",
    scale=1000.0,                    # coordinate scale - default matches the millimetre unit this exporter always declares
    schema="IFC4",                   # IFC schema version string written into the file header
    classifier=None,                 # optional callable(name, layer) -> (STEP_ENTITY_TYPE, IFC_CLASS_NAME)
    classify_using_full_path=False,  # opt-in: also try keyword-matching the full ancestor path
)
Classification — every element needs an IFC type (IfcWall, IfcBeam, …). The built-in classify_element() tries the component's own name first, then its layer/tag, and falls back to an untyped IfcBuildingElementProxy if neither matches a known keyword. Two ways to change that:
# 1. Bring your own classifier entirely
def my_classifier(name, layer):
    if "truss" in name.lower():
        return "IFCMEMBER", "IfcMember"
    return "IFCBUILDINGELEMENTPROXY", "IfcBuildingElementProxy"

ifc.export(scene, "output.ifc", classifier=my_classifier)

# 2. Or widen the built-in classifier's search instead of replacing it
ifc.export(scene, "output.ifc", classify_using_full_path=True)
classify_using_full_path is off by default on purpose: it matches keywords against a part's full ancestor hierarchy path (e.g. a "Stud 12" under a "Wall Frame" component would match on "Wall Frame"), which is broader and noisier than matching the part's own name/layer — good for a file where individual parts are never named or tagged usefully, but can mistype an element based on its container rather than itself. Try without it first.
Automatic, no flag needed — two things carry through from the source file without any option to set: every attribute dictionary an instance carries (not just SketchUp's own Dynamic Component attributes) becomes its own Pset_<dictionary-name> in the IFC output, useful for third-party plugin data (steel-detailing tools, structural framing plugins) that would otherwise have no path into the exported model at all; and per-layer visibility carries through as IfcPresentationLayerWithStyle.LayerOn, from Scene.layer_hidden — currently correct for legacy (2013–2020) source files, while VFF (2021+) source files always report visible in this specific export path today, a known gap tracked on the roadmap.
Fragments (.frag) Export NEW — Python + C++
Direct SketchUp → ThatOpen Fragments export for BIM web viewers, no IFC intermediate
Python and C++, neither published to a package registry yet — real, fully tested, tagged GitHub releases (Python, C++). C++ measured roughly 5-9x faster end to end than the Python pipeline on the same real files. Install Python with:
pip install "openskp[fragments] @ git+https://github.com/iamahsanmehmood/openskp.git@preview-python-v1.3.2#subdirectory=packages/python"
Build C++ by checking out the preview-cpp-v1.3.1 tag directly and following README.md's C++17/CMake Quick Start (find_package(OpenSkp CONFIG REQUIRED)).
ThatOpen's Fragments is a public, IFC-agnostic FlatBuffers binary format built for fast loading in BIM web viewers (@thatopen/fragments). openskp.export.fragments writes it directly from an InstancedScene — no IFC text-generation step in between, which is the whole point: it eliminates the downstream IFC re-parse a viewer would otherwise need to do.
from openskp import SkpFile
from openskp.export import fragments

skp = SkpFile.open("model.skp")
scene = skp.build_instanced_scene()

fragments.export(scene, "output.frag")          # write the file directly
data = fragments.to_fragments(scene, raw=True)   # or get the raw flatbuffer bytes
Requires the optional fragments extra (pip install openskp[fragments], pulls in flatbuffers>=24.0). raw=False (the default) produces the zlib-compressed container the real loader expects from a file on disk; raw=True returns the uncompressed flatbuffer, matching how the loader auto-detects either.
#include <openskp/openskp.hpp>

auto skp = openskp::SkpFile::open("model.skp");
auto scene = skp.build_instanced_scene();

openskp::export_fragments(scene, "output.frag");        // write the file directly
auto data = openskp::to_fragments(scene, /*raw=*/true);  // or get the raw flatbuffer bytes
No extra CMake dependency needed — FlatBuffers is already one of the three FetchContent-fetched dependencies (alongside miniz and TinyGLTF) the C++ package pulls in for every build. Same raw semantics as Python's to_fragments().
What's carried through from the source file
Real nested spatial hierarchy — matches the source file's own component nesting, not a flattened list. A component with both its own geometry and a nested sub-component instance gets both correctly represented.
Display names — the same priority build_instanced_scene() already uses (plugin attribute dictionaries' name/label/code, falling back to the definition name), written in the exact ["Name", value, "STRING"] attribute convention the real IfcImporter uses for its own name field.
Per-item GUIDs — the source file's real per-instance SketchUp GUID when available (VFF/2021+ files), a stable synthetic GUID otherwise.
Non-unit scale and mirrored instances — baked into geometry, since Fragments' Transform struct has no scale field at all. Shells still dedupe by (resource, primitive, baked scale), so instances sharing a scale still share geometry.
Per-layer default visibility — via a Model.metadata JSON sidecar (see the limitation below — this is not part of the public Fragments schema).
Known limitations — stated plainly
The Fragments schema has no native visibility field anywhere. Any consumer needs to know to read Model.metadata's JSON for per-layer hidden state — a convention this project defined for this purpose, not a public part of the format.
Model.guid (the single model-level identifier, distinct from each item's own per-instance guid above) is still an unpopulated placeholder.
Python and C++ have this today (both GitHub-only preview tags — Python, C++); C++ measured roughly 5-9x faster end to end than the Python pipeline on the same real files. A community TypeScript port is open (PR #276) but its required CI check is currently failing, so it isn't usable yet. .NET and Dart have no work started.
Full reference: DEVELOPER_GUIDE.md § Fragments export. Cross-language status: LANGUAGE_PARITY.md. What's next: ROADMAP.md.
Language & Version Support
Every feature, which of the 5 languages has it, what's unreleased, and which real SketchUp file versions actually parse
Package versions
LanguageReleased versionUnreleased on main?
🐍 PythonPyPIYes — preview-python-v1.3.2, GitHub-only, not on PyPI yet
📘 TypeScriptnpmYes — writer memory fix (GrowableBytes)
🚀 .NETNuGetNo
🎯 Dartpub.devNo
⚙️ C++GitHub ReleasesYes — preview-cpp-v1.3.1, GitHub-only preview
Each language releases independently — version numbers can legitimately diverge rather than always moving in lockstep. See CONTRIBUTING.md for why.
Full feature matrix
✅ shipped & released · 🔶 shipped on main, not released yet · ❌ not yet ported.
FeaturePythonTypeScript.NETDartC++
Parse VFF (2021+) & legacy MFC (2013–2020)✅✅✅✅✅
Geometry, layers, materials/textures, styles, Dynamic Components✅✅✅✅✅
Single attribute dictionary per entity✅✅✅✅✅
Attribute dicts: multiple dictionaries per entity✅❌❌❌✅ 🔶 GitHub-only
Attribute dicts: full 9-value-type support (Point3d/Length/nested lists)✅❌❌❌❌ str only
Attribute dicts surfaced in GLB/JSON metadata export (not just IFC Psets)🔶 GitHub-onlyn/an/an/a🔶 GitHub-only
VFF pages/scenes + dimension parsing✅✅✅✅✅
Legacy (pre-2021) pages/scenes reading✅❌❌❌🔶 GitHub-only
Construction lines/points reading✅❌❌❌🔶 legacy only, GitHub-only
VFF per-layer-hidden flag reading🔶❌❌❌🔶 GitHub-only
mesh_index[...].properties populated✅✅✅✅✅
model.layers in file order (not alphabetical)✅✅✅✅✅
Scene baking, instancing-preserving scene output✅✅✅✅✅
earcut triangulation (perf + correctness fix)🔶n/an/an/an/a
Large-file streaming fix (#264)🔶not checkednot checkednot checkednot checked
Writer: materials/layers/definitions/groups/faces/curves/images✅✅✅✅✅
Writer: dimensions, section planes, construction geometry✅❌❌❌❌
Editor (open_existing()), code generator✅✅✅✅✅
codegen round-trips applied_width/opacity❌❌❌❌❌ — gap in all 5
Export: GLB / OBJ / STL / PLY / DXF / IFC4 / JSON✅✅✅✅✅
IFC export correctness fixes (units, layer visibility, Psets, classification)🔶not checkednot checkednot checked🔶 GitHub-only
Fragments (.frag) export🔶 GitHub-only🔶 PR #276 open, CI failing❌ not started❌ not started🔶 GitHub-only
Full detail (including "not independently checked" nuance and why): docs/LANGUAGE_PARITY.md. Cross-language porting backlog: issue #285.
Real SketchUp file version support
Not every real old-format file parses today — stated plainly. A 14-file real-world version sweep (versions 3 through 2025, same project across its save history) found 8 parse, build, and export cleanly; 6 fail with 5 distinct error signatures.
SketchUp versionResultError / notes
2014, 2015, 2016, 2017, 2018, 2020, 2021, 2025✓ CleanParse, build, and export cleanly — consistent output across all 8
V3❌ Failsexpected a string record
V4❌ Failstexture object is not a dib
V6❌ Failsdefinition list misaligned
V7, V8, 2013❌ Failsclass-ref to non-class slot N (CAttributeNamed) — root cause narrowed, not yet found
2019❌ Failsimplausible def entity count
Only investigated against the Python legacy MFC reader — each of the other 4 languages has its own independently-implemented parser, so this isn't assumed fixed or broken the same way there. Tracked in issue #284 — a real repro .skp file for any of these speeds up narrowing it down further.
Web Viewer
Drag-and-drop 3D viewer, built on the TypeScript package
The live web viewer is a full drag-and-drop 3D viewer built on the TypeScript package and Three.js. It calls both parseSkp() (for version/layers/materials metadata) and buildScene() (for renderable meshes) on the same buffer.
The viewer runs entirely in your browser tab, which has a fixed JavaScript memory limit that can't be raised the way Node's --max-old-space-size can. Files at or above 50MB show an explicit warning before attempting to load, since a load that exceeds the tab's heap can freeze it with no recoverable error. For large files, use the Python/.NET/Dart packages directly instead.
Known Cross-Language Differences
Honest list — none are "wrong," but code written for one language won't port directly
Root-level definition access
This was resolved - all five languages now agree. model.definitions is strictly numeric-keyed in every language; root is a separate value with the same Definition shape: a plain field in Python/Dart (model.root), a property in TypeScript/.NET (model.root/model.Root), and an accessor method in C++ (model.root()).
TypeScript memory scaling
See Performance & Memory above — TypeScript needs significantly more heap than the other four languages for the same file, with no config-based workaround for files above roughly 250MB today.
.NET static SkpFile API shape
The .NET port exposes SkpFile as a static class with factory methods (SkpFile.Parse, SkpFile.BuildScene, SkpFile.Open), rather than requiring an instantiated file handle object before calling .Parse() — a deliberate C# idiom matching standard .NET framework designs like System.IO.File.
C++ materials_by_id() helper
SkpModel::materials_by_id() returns a std::map<EntityId, Material*> (a method, not a plain field) — matching the enumerable dictionary/map the other four languages expose as materials_by_id/materialsById/MaterialsById.
Contributing
Every contribution matters — bug fixes, features, docs, new platforms
Report an Issue
Found a bug, or a file that doesn't parse right? Open an issue with a repro if you can.
Pull Request
Fork, add tests, submit a PR. See CONTRIBUTING.md for the per-language setup.
Full Docs on GitHub
Developer Guide, Observability Guide, Architecture, and the raw binary format spec.
bash
# Clone and set up
git clone https://github.com/iamahsanmehmood/openskp.git
cd openskp

# Python
cd packages/python && python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" && pytest

# TypeScript
cd packages/typescript && npm install && npm test

# .NET
cd packages/dotnet/OpenSkp.Tests && dotnet test

# Dart
cd packages/dart && dart pub get && dart test

# C++
cmake -S packages/cpp -B build/cpp -DOPENSKP_BUILD_TESTS=ON
cmake --build build/cpp && ctest --test-dir build/cpp --output-on-failure