dedalo

module
v0.0.0-...-e095fa6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: MIT

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.obj and 102 MB/s on the seamed input.

The in-repo go test -bench figure (~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

  1. Preserve by default. No trimesh-style process-on-load.
  2. Opt-in mutation. Triangulate, expand and GPU pack are named.
  3. Explainable. LoadReport says what happened and why counts changed.
  4. Soft materials by default. A missing .mtl does not kill geometry, and Offline can fail closed.
  5. Pure Go. No CGO. The WASM build of ./obj is smoke-tested, not a full web app stack.
  6. I/O is float64. GPU packing is an explicit step.
  7. 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL