Pure-Rust Wavefront OBJ + MTL 3D mesh codec. Implements the
oxideav_mesh3d::Mesh3DDecoder and Mesh3DEncoder traits, plugging into
the wider OxideAV codec ecosystem.
OBJ is the universal mesh-interchange format published by Wavefront Technologies in the early 1990s as Appendix B of the Advanced Visualizer manual. This crate implements the polygonal subset (the part that modern loaders actually load):
v/vt/vnvertex data — with the optionalw4th component on positions (rational weight per spec §"v x y z w" — preserved verbatim throughPrimitive::extras["obj:vertex_weight"]) and the optionalv/wextra components on UVs. The widely-deployed MeshLab / libigl / Meshroom / OpenCVv x y z r g bper-vertex-colour extension is accepted at parse time, surfaced throughPrimitive::colors[0](alpha pinned to 1.0 since the extension only spells out three channels), and re-emitted at the original token width —xyz,xyzw,xyzrgb, orxyzwrgb— using thePrimitive::extras["obj:vertex_color_present"]bitmap so partial- colouring inputs preserve their per-vertex partition on round-trip rather than fabricating synthetic white. 5-floatvlines are rejected as ambiguous.ffaces in all four index syntaxes (v,v/vt,v//vn,v/vt/vn), with 1-based indexing and the negative-index relative-from-end shorthand. Polygons (n-gons) are fan-triangulated on read; the original per-face arity is stashed inMesh::extras["obj:original_face_arities"]so the encoder can re-emit n-gons rather than triangles.lline elements →Topology::LineStripfor a singlelelement with three or more distinct vertices,Topology::LineLoopwhen the polyline closes (last vertex equals the first; redundant closing index dropped), orTopology::Linesfor multi-lprimitives and 2-vertex segments. The encoder picks the matching emit shape:LineStripwrites the natural index list,LineLoopre-appends the first index to spell out the closing edge, andLinesrejoins contiguous segment pairs into one polyline rather than emitting onel v1 v2per pair.ppoint elements →Topology::Points. Multi-vertexp v1 v2 v3 …lines pack onto one element list; mixing point and face/line elements under oneusemtlsplits into one primitive per topology.mg <group_number> [res]merging-group state-setting → preserved verbatim inPrimitive::extras["obj:merging_group"]; a change mid-stream splits the primitive (mirrorssbehaviour).bevel on/off,c_interp on/off,d_interp on/off, andlod <level>display attributes → captured per-primitive inPrimitive::extras["obj:bevel"]/["obj:c_interp"]/["obj:d_interp"]/["obj:lod"]. Mid-stream changes split the primitive so each one carries one consistent assignment per attribute.o <name>→ oneMeshper object directive (or a single mesh if the file has noo).g name1 name2 …→ multiple group names per line, captured inPrimitive::extras["obj:groups"]and re-emitted on a singlegline.s 1/s off/s 0smoothing groups → preserved verbatim inPrimitive::extras["obj:smoothing_group"]; a smoothing-group change mid-object splits the primitive so each one carries a single consistent assignment. The spec calls smoothing groups "a quick way to specify vertex normals", soObjDecoder::with_normal_generation(NormalGeneration::FromSmoothingGroups)(opt-in) synthesises vertex normals forvn-less faces: an active group (s 1, …) gives smooth area-weighted averaged normals across shared vertices, while smoothing off (s off/s 0/ nos) gives faceted per-face normals (vertices de-shared so the hard edge between adjacent faces survives). Explicitvndata supersede smoothing groups, so those primitives are untouched. Generated normals are flaggedPrimitive::extras["obj:generated_normals"]("smooth"/"flat") and the encoder skips them, keeping the round-tripvn-free.mtllib <file.mtl> [<file2.mtl> …]andusemtl <name>— eachusemtlswitch starts a freshPrimitiveso a multi-material OBJ becomes aMeshwith N primitives, each with its ownMaterialId.maplib <lib1.map> [<lib2.map> …]andusemap <name>/usemap off— the texture-map sibling ofmtllib/usemtlper spec §"maplib" / §"usemap". Library names ride onScene3D::extras["obj:maplibs"]; the per-primitive binding lands inPrimitive::extras["obj:usemap"]. A mid-streamusemapswitch opens a fresh primitive (same state-setter shape asusemtl/s/mg); ausemtlswitch inherits the activeusemapbinding (the two operate independently per spec).- Free-form geometry (
vpparameter-space vertices,cstype,deg,curv,curv2,surf,parm,trim,hole,scrv,sp,end, plus the supersededbzp/bsppatches and the supersededcdcCardinal-curve /cdpCardinal-patch /ressegment-count statements) — captured verbatim intoScene3D::extras["obj:vp"](1-based parallel vertex pool) andScene3D::extras["obj:freeform_directives"](sequence of[keyword, arg1, arg2, …]arrays). The encoder replays both after the polygonal section so a decode → encode round-trip preserves the directive order and arguments. Eachvpline's original token width (1, 2, or 3 coordinates per spec §"vp u v w") rides on a parallelScene3D::extras["obj:vp_widths"]vector so the re-emit is byte-faithful even when a trailing coordinate is a genuine zero (vp 0.5 0.0for av = 0surface special point, orvp 0.5 0.5 0.0for a zero-weight rational trim control point) rather than collapsing on a strip-trailing-zeros heuristic. Verbatim by default; opt-in tessellation ofcurv3D space curves,curv22D parameter-space trimming curves, and Bezier / B-spline / Cardinal / Taylor / basis-matrixsurfsurfaces is available viaObjDecoder::with_curve_tessellation(samples)(see the per-round notes below). Tessellated surfaces carry smooth per-vertex normals (area-weighted face-normal averages over the emitted triangle lattice, front-oriented per spec §"surf": u-right / v-up CCW winding) so a downstream renderer or glTF exporter shades curved surfaces smoothly rather than flat; the normal buffer covers the base lattice plus trim-boundary andscrv-constraint vertices. Actech cparm <res>curve-approximation directive (spec §"ctech technique resolution") in acstype … endblock overrides the uniform tessellation budget for that block: eachcurvis evaluated atn = round(res × degree)subdivisions per the spec's constant-parametric formula (res 0⇒ a single line segment), with the effective count surfaced throughPrimitive::extras["obj:curve_samples"]/["obj:curve_ctech_cparm_res"]. The geometricctech cspace/ctech curvtechniques (iterative chord-length / curvature refinement) stay on the uniform budget. Its surface analog,stech cparma ures vres/stech cparmb uvres(spec §"stech technique resolution"), overrides the surface lattice density the same way: eachsurfpatch is evaluated atn = round(res × degree)subdivisions per parametric direction, with the shared isotropic lattice driven from the finer (max) of the two per-direction counts so the coarser direction is never under-sampled (cparma 0 0⇒ two triangles per patch;cparmbapplies its one value to both directions). The effective count rides onPrimitive::extras["obj:surface_samples"]and the source[ures, vres]pair on["obj:surface_stech_cparm_res"]; the geometricstech cspace/stech curvtechniques stay on the uniform budget.
The companion MTL parser/serialiser handles:
- Phong colours (
Ka/Kd/Ks/Ke) → glTFbase_color(fromKd) +emissive_factor(fromKe);Ka/Ksand theNsexponent are stashed inMaterial::extrasfor round-trip. Each ofKa/Kd/Ksaccepts the three mutually-exclusive forms per spec — plain RGB (r g b, with g/b defaulting to r),spectral file.rfl [factor](factor defaults to 1.0), andxyz x [y z]CIEXYZ (y/z defaulting to x). The spectral and xyz variants ride on sibling extras keys (mtl:K{a,d,s}:spectral/mtl:K{a,d,s}:xyz) so a re-emit reproduces the operator's chosen form; theKd spectral/Kd xyzvariants additionally suppress the canonicalKd r g bemit driven bybase_color. - Transparency (
ddissolve /Tr = 1 - d) →AlphaMode::Blendbase_color.a. Thed -halo factororientation-dependent variant is detected and re-emitted viaMaterial::extras["mtl:d_halo_factor"].
- Index of refraction (
Ni) and illumination model (illum) → extras. The rawilluminteger lands inMaterial::extras["mtl:illum"]unchanged; for in-spec models 0–10, the parser additionally surfaces the spec's per-model "Properties that are turned on in the Property Editor" summary table (Wavefront MTL spec §"illum illum_#") as a decomposed object inMaterial::extras["mtl:illum_props"]with the nine stable flag keyscolor/ambient/highlight/reflection/ray_trace/transparency_glass/transparency_refraction/fresnel/casts_shadow_on_invisible. Out-of-spec integers (negative or> 10) keep the raw integer but omitmtl:illum_props. The decomposition is parse-time-only; the encoder still emits exactly oneillum Nline per material. - Transmission filter — three mutually-exclusive forms per spec:
Tf r g b(withg/bdefaulting tor),Tf spectral file.rfl factor→Material::extras["mtl:Tf:spectral"], andTf xyz x y z→Material::extras["mtl:Tf:xyz"]. - Reflection sharpness (
sharpness) and displacement / decal / reflection maps (disp↔map_disp,decal↔map_decal,refl↔map_refl) round-trip via extras. - Typed reflection maps —
refl -type sphere fileand the six-facerefl -type cube_top|cube_bottom|cube_front|cube_back|cube_left|cube_rightcubemap. Sphere lands asMaterial::extras["mtl:refl:sphere"]; the six face lines bundle intoMaterial::extras["mtl:refl:cube"](one entry per face) so they don't collapse onto each other under last-write-wins; the encoder re-emits onerefl -type <kind> ... fileline per face / sphere in deterministic order. - Texture references (
map_Kd→base_color_texture,map_Bump→normal_texture,map_detc.) emitted asImageData::External { uri, mime: None }— the caller resolves paths against the OBJ file's directory. Leading-flag valueoption chunks (-blendu,-blendv,-cc,-bm,-boost,-mm,-clamp,-imfchan,-o,-s,-t,-texres) are parsed out of the filename and surfaced viaMaterial::extras["mtl:<map_name>:options"]; the encoder splices them back inline. The offset / scale / turbulence flags-o/-s/-tare variable-arity per spec (umandatory,v/woptional): the parser consumes only the numeric run that follows each flag, somap_Kd -o 0.2 logo.mpckeepslogo.mpcas the filename rather than swallowing it as the omittedv. A parallel typed view rides onMaterial::extras["mtl:<map_name>:options_typed"]with stable primitive-valued keys per flag (boolfor theon/offflags,f64forbm/boost/texres,[base, gain]formm,[u, v, w]foro/s/t,Stringforimfchanandtype) so consumers can read each option without re-parsing the raw token array. The typed key is parse-time-only; encoder output is still driven by the raw:optionsarray. - Wavefront-PBR extension scalars (
Prroughness,Pmmetallic,Pcclearcoat,Pcrclearcoat-roughness,Pssheen,aniso/anisoranisotropy) →Material::roughness/Material::metallicforPr/Pm, with the rest stashed on extras. The metallic-roughness mapsmap_Pr/map_Pmdrivemetallic_roughness_texture; the remaining PBR map siblingsmap_Ps/map_Pc/map_Pcr/map_aniso/map_anisor— which glTF can't channel-map — ride verbatim onMaterial::extras["mtl:<map>"](with-flag valueoption chunks parsed out) so a decode → encode round-trip is lossless for the full PBR map family rather than dropping the unrepresentable ones. map_aat onper-material texture anti-aliasing toggle (spec §"map_aat on") → booleanMaterial::extras["mtl:map_aat"], round-tripped as the exacton/offtoken.
Both decoders are registered against Mesh3DRegistry under the
default-on registry cargo feature; drop the feature for a free-standing
build that only exposes ObjDecoder / ObjEncoder and the
oxideav_mesh3d standalone trait surface.
For path-based loading, obj::parse_obj_from_path resolves
mtllib foo.mtl references against the OBJ file's parent directory
(handles multiple MTL files per line). For round-trip mirroring of
inputs that used negative-from-end indices, the encoder accepts
ObjEncoder::new().with_negative_indices(true) (or the same flag on
obj::SerializeOptions).
The Wavefront spec is mirrored in the OxideAV docs repository:
docs/3d/obj/wavefront-obj-spec.txt— Martin Reddy plain-text Appendix B1 mirror.docs/3d/obj/wavefront-mtl-spec.html— Paul Bourke MTL mirror (carries the originalCopyright 1995 Alias|Wavefront, Inc.notice).docs/3d/obj/paulbourke-obj-reference.html— Paul Bourke OBJ cross-check mirror.
This crate was implemented strictly from those references.
Production-ready for the polygonal OBJ + MTL feature set, with broad free-form (curve/surface) support behind an opt-in tessellator.
Polygonal core: v / vt / vn (with optional w weight and the
v x y z [w] r g b per-vertex-colour extension), f faces in all four
index syntaxes (1-based, negative-from-end), l line elements
(LineStrip / LineLoop / Lines), p point elements, o objects,
g groups (multi-name), s smoothing groups, mg merging groups,
bevel / c_interp / d_interp / lod display attributes,
mtllib / usemtl, and maplib / usemap. State-setter directives
split a primitive on mid-stream change; original n-gon arities are
preserved so the encoder re-emits polygons rather than triangles. With
ObjDecoder::with_normal_generation(…), vn-less faces get vertex
normals synthesised from their smoothing-group state (smooth-averaged
inside a group, faceted when smoothing is off) for direct rendering.
MTL: Phong colours (Ka / Kd / Ks / Ke, each in plain-RGB /
spectral / xyz forms), transparency (d / Tr, including
d -halo), Ni, illum (with the spec property-table decomposition
for in-spec models 0–10), Tf (all three forms), sharpness,
displacement / decal / reflection maps (refl -type sphere / cube),
texture references with full -flag value option parsing (both raw and
typed views), the Wavefront-PBR extension (Pr / Pm / Pc / Pcr /
Ps / aniso / anisor scalars; map_Pr / map_Pm metallic-roughness
maps; map_Ps / map_Pc / map_Pcr / map_aniso / map_anisor
verbatim-round-trip maps), and map_aat.
Free-form geometry: vp, cstype, deg, curv, curv2, surf,
parm, trim, hole, scrv, sp, con, ctech / stech,
call / csh, shadow_obj / trace_obj, and the superseded
bzp / bsp / cdc / cdp / res statements all round-trip
verbatim. With ObjDecoder::with_curve_tessellation(samples), curv /
curv2 curves and surf surfaces of every basis kind — Bezier,
B-spline / NURBS, Cardinal (Catmull-Rom), Taylor, and basis-matrix
(bmatrix) — are evaluated to real LineStrip / Triangles geometry
on synthetic meshes, including multi-patch decomposition, trim / hole
sub-cell boundary re-meshing, scrv special-curve triangle-edge
embedding, sp special points, and con connectivity seams. The
superseded cdc (Cardinal curve) and bzp (16-point bicubic Bezier
patch) statements tessellate too — to obj:superseded_curves /
obj:superseded_surfaces meshes — reusing the modern Cardinal / Bezier
evaluators, with the superseded res useg vseg statement modulating
their subdivision density (segment count clamped to the spec's 3..=120
range). A block-local ctech cparm (curves) or stech cparma /
cparmb (surfaces) directive overrides the uniform subdivision budget
with the spec's constant-parametric n = round(res × degree) density. The
triangulated surfaces carry smooth per-vertex normals for direct
smooth-shaded rendering. Typed
decompositions of parm / con / sp / trim-loop / ctech /
stech body statements and of the superseded bzp / bsp / cdc /
cdp geometry statements (Scene3D::extras["obj:superseded"], paired
with the active res segments) ride alongside the verbatim channel for
consumers that don't want to re-parse positional tokens.
A cargo fuzz harness (fuzz/fuzz_targets/parse_obj.rs and
parse_mtl.rs) asserts panic-freedom across the public decoder entry
points; regression cases are pinned in tests/fuzz_regressions.rs.
The .mod binary form remains out of scope.
MIT. See LICENSE.