README
¶
DEDALO
A pure-Go mesh interchange library for Wavefront OBJ/MTL, STL and PLY. It preserves topology by default, tells you what it loaded, and packs draw-ready buffers only when you ask.
Library (
obj/): zero third-party dependencies, no CGO, no Assimp, no silent process-on-load. CLI (objtool): urfave/cli/v3 · errorutils · lipgloss. Not a renderer, not MeshLab, not a full untrusted-upload sandbox. First hour:objtool hello.
API map: docs/API.md · Journeys: docs/JOURNEYS.md · Limits: docs/LIMITS.md · Lifts: docs/LIFTS.md · Goldens: docs/GOLDENS.md · Stability: docs/STABILITY.md · Benchmarks: bench/
Install
go get github.com/pydpll/dedalo/obj
go.mod declares Go 1.24, which is the floor for consumers. The obj/ package
itself builds and tests clean on 1.23; the CLI is what needs 1.24, because
errorutils requires it.
Start here (60 seconds)
go run ./cmd/objtool hello
go run ./cmd/objtool inspect testdata/cube.obj
go run ./cmd/objtool convert testdata/cube.obj /tmp/cube.stl
The one thing that makes DEDALO different
Load a file with duplicated vertex positions. DEDALO gives you back what the file said. The most popular Python loader quietly does not.
# bench/dup.obj: 6 vertices, 2 triangles, duplicated positions
v 0 0 0
v 1 0 0
v 0 1 0
v 1 0 0
v 1 1 0
v 0 1 0
f 1 2 3
f 4 5 6
$ objtool info bench/dup.obj → vertices: 6 faces: 2
>>> trimesh.load("dup.obj") → 4 vertices ← merged, by default
>>> trimesh.load("dup.obj", process=False)
→ 6 vertices
Measured, not asserted. Run it yourself with bench/py/process_claim.py.
Five loaders were probed this way (DEDALO, obj-rs, MeshIO.jl, pywavefront, trimesh).
All returned 6 vertices except trimesh's default, the only one that alters the mesh
on load. DEDALO has no such mode. Mutation is always a named, opt-in step, and it
shows up in LoadReport.ProcessSteps.
Performance
Same file, same machine, best of 5 runs, warm-up discarded. Every library was checked to return the same mesh first (90,601 positions / 180,000 faces), so these are like-for-like.
Two inputs, because they stress different things: grid.obj (17.3 MB, no UV seams)
and grid_seamed.obj (29.2 MB, 449,399 seam splits, which is what real exporters emit).
Task A — load the file into the library's mesh
| Library | Language | grid.obj | grid_seamed | vs DEDALO (grid.obj) |
|---|---|---|---|---|
| tinyobjloader 2.0 | C++ | 81.8 ms | 111.7 ms | 0.56x |
obj-rs 0.7.4 parse_obj |
Rust | 120.0 ms | 199.5 ms | 0.82x |
DEDALO Parse |
Go | 145.5 ms | 285.5 ms | 1.00x |
| go-obj | Go | 207.4 ms | 369.9 ms | 1.43x |
| gwob 1.0.0 | Go | 324.0 ms | 550.2 ms | 2.23x |
trimesh 5.0.0 process=False |
Python | 351.2 ms | 601.7 ms | 2.41x |
| trimesh 5.0.0 default | Python | 391.1 ms | 847.6 ms | 2.69x |
| MeshIO.jl | Julia | 778.2 ms | 2081.5 ms | 5.35x |
| pywavefront 1.3.3 | Python | 902.0 ms | 1115.5 ms | 6.20x |
Task B — draw-ready single-index buffers
| Library | Language | grid.obj | grid_seamed |
|---|---|---|---|
tobj 4.0.5 single_index |
Rust | 74.6 ms | 175.5 ms |
obj-rs load_obj |
Rust | 136.3 ms | 257.2 ms |
DEDALO Parse + ToGPU |
Go | 152.0 ms | 411.5 ms |
| gwob (expands by default) | Go | 325.5 ms | 548.6 ms |
| trimesh → float32 arrays | Python | 354.7 ms | 618.0 ms |
Expansion is verified equal, not assumed. On the seamed input DEDALO, obj-rs and tobj all return 540,000 vertices / 540,000 indices, and the harness prints those counts.
What these numbers actually say
- Fastest pure-Go OBJ loader measured: 1.4x faster than go-obj, 2.2x than gwob.
- 2.4x faster than trimesh's fidelity load, 5.4x faster than MeshIO.jl.
- Slower than C++ and Rust. tinyobjloader is 1.8–2.6x faster, and tobj is 2.0–2.3x faster on the packing path. If raw parse speed is your only criterion and a cmake/cargo dependency is acceptable, use those. DEDALO's trade is a pure-Go, CGO-free, cross-compilable dependency that reports what it did.
- DEDALO sustains 119 MB/s on
grid.objand 102 MB/s on the seamed input.
The in-repo
go test -benchfigure (~30 MB/s) is a different workload: a ~310 KB synthetic mesh where per-allocation cost dominates. It is not comparable to the large-file throughput above, and neither number is wrong.
Reproduce these numbers
Every harness is in bench/, a separate Go module, so the
competitors never enter the root go.mod. The Go table regenerates in two commands:
cd bench
go run ./gen /tmp/grid.obj 300 # byte-reproducible 17.3 MB input
go run ./go /tmp/grid.obj # DEDALO vs gwob vs go-obj, with output counts
bench/README.md has the C++, Rust, Python and Julia invocations, each installing
into a throwaway location rather than your system.
Hardware: Intel Core 7 240H, Linux 6.12. Versions: DEDALO 0.6.3 · tinyobjloader 2.0
(g++ 16.1.1 -O2) · tobj 4.0.5 / obj-rs 0.7.4 (rustc 1.94.0 --release) · gwob 1.0.0 ·
go-obj (2019-01-06) · trimesh 5.0.0 / pywavefront 1.3.3 (CPython) · MeshIO.jl (Julia 1.12.6).
Calls used: trimesh.load(p, process=False), pywavefront.Wavefront(p, collect_faces=True, create_materials=True), tinyobj with config.triangulate = true,
tobj with single_index: true, triangulate: true.
Numbers are one machine's. Re-run bench/ on yours before quoting them.
Ease of use — the same job, four ways
Job: open an OBJ and hand a GPU an interleaved position/normal/UV buffer.
// DEDALO: the expand is named, and the result carries a receipt
res, _ := obj.LoadForGPU("model.obj")
gpu, _ := res.Mesh.ToGPU(obj.GPUOptions{Layout: obj.LayoutInterleavedPNU})
// gpu.Interleaved, gpu.Indices, gpu.MaterialRanges, gpu.ProcessSteps
// tobj: one call, single_index does the expansion. Genuinely concise.
let (models, mats) = tobj::load_obj(&path, &tobj::LoadOptions {
triangulate: true, single_index: true, ..Default::default() })?;
# trimesh: concise, but you must know to pass process=False to keep your topology
m = trimesh.load(path, process=False)
verts, faces = m.vertices.astype("float32"), m.faces.astype("uint32")
// tinyobjloader: fastest, but you assemble the interleaved buffer yourself
tinyobj::ObjReader reader; reader.ParseFromFile(path, config);
for (const auto &s : shapes) for (const auto &i : s.mesh.indices) { /* pack */ }
Honest reading: tobj matches DEDALO for brevity on this job and beats it on speed.
DEDALO's edge is not fewer lines. It is that nothing happened to your mesh that you
did not name, and gpu.ProcessSteps tells you exactly what did.
Error messages when attributes are missing
Ask for a position/normal/UV layout on a mesh that has neither normals nor UVs:
| Library | Message |
|---|---|
| DEDALO | mesh has no normals (use MissingAttrInvent to fill defaults, or pick a layout that omits them) |
| obj-rs | LoadError { kind: InsufficientData, message: "Tried to extract normal and texture data which are not contained in the model" } |
Both refuse correctly. DEDALO names the option that fixes it.
Quick start — four paths
file ── Load (Preset* / LoadFor*) ──► *Mesh + LoadReport
│ ProcessSteps = receipt
▼
ApplyProcess? (optional named menu)
▼
ToGPU / Write* / Validate*
1. Fidelity (CAD, diffs, topology)
res, err := obj.LoadForFidelity("model.obj")
if err != nil { log.Fatal(err) }
fmt.Print(res.Report) // source vs result counts, materials, warnings
// res.Mesh.Vertices[i] still means file vertex i (no expand)
2. Draw-ready packing (not a renderer)
res, err := obj.LoadForGPU("model.obj")
if err != nil { log.Fatal(err) }
gpu, err := res.Mesh.ToGPU(obj.GPUOptions{
Layout: obj.LayoutInterleavedPNU, // needs vn+vt or invent
FlipUV: true,
MissingAttr: obj.MissingAttrInvent, // explicit; default refuses silent invent
PreferU16: true,
})
// gpu.Interleaved, gpu.Indices / IndicesU16, gpu.MaterialRanges, gpu.ProcessSteps
3. Untrusted / CI ingest
res, err := obj.LoadForUntrusted(path)
// errors.Is(err, obj.ErrResourceLimit | ErrAssetPathBlocked | ErrMissingMaterial)
4. Round-trip assets (CI / tooling)
mesh, err := obj.ParseFile("in.obj") // OBJ only
_ = obj.WriteBundle("out", "model", mesh) // model.obj + model.mtl
_ = obj.WriteFileByExt("out/model.stl", mesh) // multi-format by extension
// multi-format open:
mesh, err = obj.ReadFile("model.stl")
Missing mtllib files warn by default under Fidelity. Use LoadForOffline to fail
closed. Geometry still loads under soft mode, so check res.Report.Warnings().
Not guaranteed: watertight solids, mikktspace tangents, full PLY property sets,
faster-than-Parse concurrent ingest. See LIMITS.md.
Index model (teach once)
OBJ file → Mesh (multi-index, float64) → ExpandSingleIndex → ToGPU (float32)
↑ default / PresetFidelity PresetGPU does both
Features (scoped)
Every row states what the feature will not do, because that is usually the question.
| Feature | Purpose | Scope limit |
|---|---|---|
| Presets Fidelity / GPU / Offline | Named policies | Offline ≠ Fidelity (MTL soft vs hard) |
ParseWithReport |
Counts, seams, process steps | Explains; does not prove "why CAD did X" |
ToGPU |
float32 pack + oneshot expand | Not upload or render; invents only if you ask |
WriteBundle / STL / PLY |
Interchange | Binary PLY is LE common path |
| Asset sandbox | mtllib/maps confined to search roots by default | Opt-in absolute/CWD via AssetPolicy; not bulletproof against symlink games |
PresetUntrusted / budgets |
Caps + fail-closed for hostile inputs | Not a full upload sandbox |
Validate / ValidatePrint / ValidatePrintWelded |
Index hygiene, print heuristic, topology after STL soup weld | Heuristics, not a slicer green light |
WeldByPosition |
STL soup → topology | Position-only weld |
ComputeTangents + LayoutInterleavedPNUT |
Lengyel-style tangents, packed after an explicit call | Not full mikktspace |
WithTriangulateMode |
Fan, earcut, or reject nonconvex | Fan is the default; earcut falls back to fan on failure (earcut_fallback_fan) |
ApplyProcess / LoadForGPU |
Named post-process menu + cookbooks | Opt-in only, never on bare Parse |
FacesByMaterial |
Multi-draw grouping without reordering | Indices only |
MeshBuilder + primitives |
Procedural generation | Cube, plane, sphere |
ParseConcurrent |
Experimental parallel tokenize | Often slower than Parse; prefer Parse |
| Goldens + persona tests | Spot/cube counts, Avery/Nyx/Remix journeys | Fixtures, not a conformance suite |
API map (decision table)
Full tables, fail modes and trimesh migration live in docs/API.md.
| Need | Call |
|---|---|
| OBJ + report / materials | ParseFileWithReport or LoadFor* |
Multi-format open (.obj/.stl/.ply) |
ReadFile |
| Topology / vertex identity | LoadForFidelity |
| Draw-ready load policy | LoadForGPU then ToGPU |
| Fail-closed MTL | LoadForOffline |
| Untrusted budgets + sandbox | LoadForUntrusted |
| Named post-steps (opt-in) | ApplyProcess |
| Multi-format write by ext | WriteFileByExt (not WriteFile) |
| OBJ + MTL pair | WriteBundle |
ParseFile and WriteFile are OBJ-only, and give a clear error on .stl/.ply.
Options
obj.ParseFile(path, obj.PresetGPU()...)
// or compose
obj.ParseFile(path,
obj.WithTriangulateMode(obj.TriangulateEarcut),
obj.WithExpandSingleIndex(true),
obj.WithSoftMaterials(true),
)
Fail modes (soft vs hard)
| Situation | Fidelity default | Offline / Untrusted |
|---|---|---|
| Missing mtllib | warn, mesh OK | error |
| Path escape in maps | blocked / soft | blocked / hard |
| Huge input | unlimited | ErrResourceLimit |
CLI (objtool)
The CLI's dependencies stay with the CLI. Importing
github.com/pydpll/dedalo/obj gets you pure Go stdlib.
go run ./cmd/objtool --help
go run ./cmd/objtool hello
go run ./cmd/objtool info testdata/cube.obj
go run ./cmd/objtool inspect testdata/cube.obj
go run ./cmd/objtool inspect testdata/cube.obj --gpu # invents attrs; library default does not
go run ./cmd/objtool pack testdata/cube.obj
go run ./cmd/objtool validate testdata/cube.obj
go run ./cmd/objtool convert in.obj out.stl --gpu
go run ./cmd/objtool bundle in.obj /tmp/out/model
go run ./cmd/objtool gen sphere /tmp/sphere.obj
Design notes
- Preserve by default. No trimesh-style process-on-load.
- Opt-in mutation. Triangulate, expand and GPU pack are named.
- Explainable.
LoadReportsays what happened and why counts changed. - Soft materials by default. A missing
.mtldoes not kill geometry, and Offline can fail closed. - Pure Go. No CGO. The WASM build of
./objis smoke-tested, not a full web app stack. - I/O is float64. GPU packing is an explicit step.
- Documented limits. LIMITS.md is maintained alongside the code.
Layout
github.com/pydpll/dedalo
├── obj/ # library, zero third-party dependencies
├── cmd/objtool/ # CLI
├── cmd/meshview/ # OBJ → PNG software raster
├── bench/ # comparison harnesses (separate module)
├── docs/ADVANCEMENT.md # design thesis
├── docs/GOLDENS.md # topology goldens
├── docs/GENERICS_PLAN.md # future parametric mesh plan
├── examples/procedural/
└── testdata/ # fixtures (+ optional corpus/)
Develop
go test ./...
go test ./obj/ -run Corpus -timeout 180s
go test -bench=. -benchmem ./obj
go run ./cmd/objtool inspect testdata/cube.obj --gpu
go build -o /tmp/meshview ./cmd/meshview
/tmp/meshview testdata/cube.obj /tmp/cube.png 40 20
License
MIT. See LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
meshview
command
meshview loads an OBJ with DEDALO and writes a PNG preview (software raster).
|
meshview loads an OBJ with DEDALO and writes a PNG preview (software raster). |
|
objtool
command
Command objtool inspects, validates, transforms, and converts meshes.
|
Command objtool inspects, validates, transforms, and converts meshes. |
|
examples
|
|
|
procedural
command
Example: procedural mesh generation and OBJ export.
|
Example: procedural mesh generation and OBJ export. |
|
internal
|
|
|
raster
Package raster is a tiny pure-Go mesh software rasterizer for test showcases.
|
Package raster is a tiny pure-Go mesh software rasterizer for test showcases. |
|
Package obj implements Wavefront OBJ/MTL reading and writing, mesh geometry primitives, procedural builders, draw-ready packing, and validation.
|
Package obj implements Wavefront OBJ/MTL reading and writing, mesh geometry primitives, procedural builders, draw-ready packing, and validation. |