oxideav-obj
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.mtllib <file.mtl> [<file2.mtl> …]andusemtl <name>— eachusemtlswitch starts a freshPrimitiveso a multi-material OBJ becomes aMeshwith N primitives, each with its ownMaterialId.- Free-form geometry (
vpparameter-space vertices,cstype,deg,curv,curv2,surf,parm,trim,hole,scrv,sp,end, plus supersededbzp/bsppatches) — 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. Verbatim by default; opt-in tessellation ofcurvcurves and Bezier / B-spline / Cardinalsurfsurfaces is available viaObjDecoder::with_curve_tessellation(samples)(see the per-round notes below).
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. - 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. - 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,-bm,-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. - Wavefront-PBR extension (
Prroughness,Pmmetallic,Pcclearcoat,Pssheen,map_Pr/map_Pm) →Material::roughness/Material::metallic/metallic_roughness_texture.
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).
Sourcing
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 — no existing OBJ loader (tinyobj, assimp, blender io_scene_obj, three.js OBJLoader) was consulted.
Status
Round 1: polygonal subset + MTL Phong + Wavefront-PBR extension.
Round 2: multi-name g lines, smoothing-group state-setting (split-on-
change), Tf / sharpness / displacement-map round-trip, path-based
loader with mtllib resolution, and an opt-in negative-index encoder.
Round 3: p point elements, mg merging groups, bevel / c_interp
/ d_interp / lod display attributes, MTL map_* option flags
(-blendu, -clamp, -bm, …) preserved through round-trip, MTL
d -halo factor, encoder polyline rejoin.
Round 4: free-form geometry (vp parameter-space vertex pool plus
the verbatim cstype / deg / curv / curv2 / surf / parm /
trim / hole / scrv / sp / end / bzp / bsp directive
sequence) — round-trips through Scene3D::extras without
tessellation.
Round 5: MTL Tf spectral / Tf xyz alternative transmission-filter
forms, refl -type sphere / refl -type cube_* typed reflection-map
sets bundled into mtl:refl:sphere / mtl:refl:cube extras, and
single-l polylines promoted to Topology::LineStrip /
Topology::LineLoop (with closure detection at decode time).
Round 6: per-vertex colour extension (v x y z r g b, MeshLab /
libigl / Meshroom de-facto) accepted on parse, populated on
Primitive::colors[0], and re-emitted at the source's original
3-/4-/6-/7-token width via the obj:vertex_color_present bitmap.
The v 4th w weight component is now preserved through
Primitive::extras["obj:vertex_weight"] rather than silently dropped.
Round 7: opt-in Bezier curve tessellation —
ObjDecoder::with_curve_tessellation(samples) evaluates every
cstype bezier (and cstype rat bezier) curv directive via de
Casteljau's algorithm and emits a real Topology::LineStrip
primitive on a synthetic "obj:curves" mesh; the rational form
uses the per-vertex 4th w weight and projects back to 3D. Each
tessellated primitive carries provenance extras
(obj:tessellated_curve, obj:curve_kind, obj:curve_degree,
obj:curve_u_range, obj:curve_samples). The free-form directive
sequence still rides on Scene3D::extras["obj:freeform_directives"]
so re-encoding regenerates the original cstype / curv / end
section unchanged; the encoder filters synthetic curve primitives
out of the polygonal output. Free-form-section position pool now
rides on Scene3D::extras["obj:positions"] (plus parallel
obj:position_weights / obj:position_colors) so curv /
surf absolute-index references stay valid across a
decode → encode → decode cycle.
Round 8: B-spline / NURBS curve tessellation — the same
with_curve_tessellation(samples) knob now also evaluates
cstype bspline and cstype rat bspline curv directives via the
Cox-deBoor recursive basis-function formula (spec §"B-spline"),
clipped against the [x_n, x_{K+1}] evaluation window of the knot
vector supplied by the most-recent parm u … body statement.
Rational form uses the per-vertex 4th w weight (NURBS form) and
projects the weighted blend back to 3D. The tessellator now does
two-pass per-cstype/end block traversal so the curv header
(which comes before the parm u … body statement per spec) can
still resolve its knot vector. Knot-vector length is validated
against the spec condition len == K + degree + 2 and curves with
incomplete data are skipped silently (the directive remains
captured for round-trip).
Round 9: Cardinal (Catmull-Rom) + Taylor polynomial curve
tessellation — with_curve_tessellation(samples) now also evaluates
cstype cardinal curv directives via the spec §"Cardinal"
conversion to Bezier control points (b0 = c1,
b1 = c1 + (c2 − c0) / 6, b2 = c2 − (c3 − c1) / 6, b3 = c2,
then cubic Bezier blend) on a sliding 4-point window, and cstype taylor curv directives via Horner's-rule polynomial evaluation
P(t) = Σ_{i=0..n} c_i · t^i per spec §"Taylor". Cardinal is cubic
only (non-cubic deg is rejected, matching the spec's "only
defined for the cubic case" requirement); Taylor honours the
[u_min, u_max] parameter clip directly on the curv line.
Synthetic primitives carry the same obj:tessellated_curve /
obj:curve_kind ("cardinal" / "taylor") / obj:curve_degree /
obj:curve_u_range / obj:curve_samples provenance and the
encoder filters them out so the source cstype / curv / end
block replays unchanged.
Round 10: basis-matrix curve tessellation — with_curve_tessellation(samples)
now also evaluates cstype bmatrix curv directives per spec
§"Basis matrix" using the user-supplied (n + 1) × (n + 1) basis
from bmat u (row-major, column index j varying fastest per
spec §"bmat u/v matrix") and the segment stride from
step <stepu> (spec §"step stepu stepv"). Each segment evaluates
P(t) = Σ_i Σ_j B[i][j] · t^j · p_{base + i} over the
control-point window c_{base+1} .. c_{base+n+1} (1-based, base = i·stepu).
The bmat and step keywords are now tracked alongside the other
free-form directives, so they round-trip verbatim through
Scene3D::extras["obj:freeform_directives"] and the encoder
replays the original cstype bmatrix block unchanged. Cubic
Bezier expressed as cstype bmatrix matches the closed-form
Bernstein evaluation; the Hermite spec example interpolates its
endpoints.
Round 11: Bezier surf surface tessellation — the same
with_curve_tessellation(samples) knob now also evaluates surf
elements under a cstype bezier (or cstype rat bezier) header
into a real Topology::Triangles grid on a synthetic mesh named
"obj:surfaces", via the bivariate tensor-product de Casteljau
algorithm (spec §"Rational and non-rational curves and surfaces",
§"Bezier"). Control points are read in the spec's row-major
u-fastest order (§"Surface vertex data — control points": "i = 0
to K1 for j = 0, …"); the surf line's v/vt/vn references are
parsed for their leading position index (negative relative-from-end
indices honoured). A single patch of declared degree deg degu degv
needs exactly (degu + 1) × (degv + 1) control points; mismatched
counts (multi-patch grids, which Bezier can't decompose without a
step stride) are left captured-only. The surface is sampled at a
(samples + 1) × (samples + 1) lattice and triangulated CCW
(front = u-right, v-up per the spec note). Each synthetic primitive
carries provenance extras (obj:tessellated_surface,
obj:surface_kind, obj:surface_degree, obj:surface_u_range,
obj:surface_v_range, obj:surface_samples) plus the shared
obj:tessellated_curve sentinel so the encoder filters it out and
replays the original cstype / deg / surf / parm / end
block unchanged from Scene3D::extras["obj:freeform_directives"].
The rational form uses each control point's 4th w weight and
projects the weighted blend back to 3D.
Round 12: B-spline / NURBS surf surface tessellation — the same
with_curve_tessellation(samples) knob now also evaluates surf
elements under a cstype bspline (or cstype rat bspline) header
into a Topology::Triangles grid on the synthetic "obj:surfaces"
mesh, via the bivariate tensor-product Cox-deBoor formula
S(u, v) = Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · d_{i,j} (spec
§"B-spline"). The per-direction control-grid size comes from the
parm u / parm v knot vectors ((len(parm u) − degu − 1) × (len(parm v) − degv − 1), spec §"B-spline" condition 6 applied
independently in u and v); the surf range is clipped against each
direction's [x_n, x_{K+1}] evaluation window. The rational (NURBS)
form blends the per-vertex w weights and projects via the weighted
denominator. Reuses the round-8 bspline_basis Cox-deBoor routine,
so a clamped quadratic patch matches the equivalent Bezier patch
sample-for-sample. Synthetic primitives carry the same
obj:tessellated_surface / obj:surface_kind
("bspline" / "rat_bspline") / obj:surface_degree /
obj:surface_u_range / obj:surface_v_range / obj:surface_samples
provenance and the encoder filters them out, replaying the original
cstype / deg / surf / parm u / parm v / end block
unchanged.
Round 13: Cardinal (Catmull-Rom) surf surface tessellation — the
same with_curve_tessellation(samples) knob now also evaluates surf
elements under a cstype cardinal (or cstype rat cardinal) header
into a Topology::Triangles grid on the synthetic "obj:surfaces"
mesh, via the bivariate tensor-product Cardinal evaluation
S(u, v) = Σ_i Σ_j C_i(u) · C_j(v) · d_{i,j} (spec §"Cardinal").
Each parametric direction reuses the spec's Cardinal→Bezier per-
segment conversion (b0 = c1, b1 = c1 + (c2 − c0) / 6,
b2 = c2 − (c3 − c1) / 6, b3 = c2) on a sliding 4-point window: the
u pass collapses every v-row, then a v pass runs over the collapsed
points. Cardinal is cubic-only per spec, so non-3 3 degrees stay
captured-only; the control grid comes from the parm u / parm v
extents (K = parm_count + 1 per direction) or, when parm carries
only the 2-value range, the square single patch (√total). A single
bicubic patch's parametric corners interpolate the interior 2×2
control block exactly (spec: "all but the first and last row and
column of control points are interpolated"). The rat cardinal form
routes to the same evaluator (unit-weight default). Synthetic
primitives carry the same obj:tessellated_surface / obj:surface_kind
("cardinal") / obj:surface_degree / obj:surface_u_range /
obj:surface_v_range / obj:surface_samples provenance and the
encoder filters them out, replaying the original
cstype / deg / surf / parm / end block unchanged.
The .mod binary form remains out of scope; non-Bezier/non-B-spline/
non-Cardinal surf surfaces (Taylor / basis-matrix), multi-patch
Bezier surface decomposition, and trim/hole loop evaluation are the
remaining gaps.
License
MIT. See LICENSE.