oxideav-obj 0.0.3

Pure-Rust Wavefront OBJ + MTL 3D mesh codec — implements oxideav-mesh3d's Decoder/Encoder traits
Documentation

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 / vn vertex data — with the optional w 4th component on positions (rational weight per spec §"v x y z w" — preserved verbatim through Primitive::extras["obj:vertex_weight"]) and the optional v / w extra components on UVs. The widely-deployed MeshLab / libigl / Meshroom / OpenCV v x y z r g b per-vertex-colour extension is accepted at parse time, surfaced through Primitive::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, or xyzwrgb — using the Primitive::extras["obj:vertex_color_present"] bitmap so partial- colouring inputs preserve their per-vertex partition on round-trip rather than fabricating synthetic white. 5-float v lines are rejected as ambiguous.
  • f faces 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 in Mesh::extras["obj:original_face_arities"] so the encoder can re-emit n-gons rather than triangles.
  • l line elements → Topology::LineStrip for a single l element with three or more distinct vertices, Topology::LineLoop when the polyline closes (last vertex equals the first; redundant closing index dropped), or Topology::Lines for multi-l primitives and 2-vertex segments. The encoder picks the matching emit shape: LineStrip writes the natural index list, LineLoop re-appends the first index to spell out the closing edge, and Lines rejoins contiguous segment pairs into one polyline rather than emitting one l v1 v2 per pair.
  • p point elements → Topology::Points. Multi-vertex p v1 v2 v3 … lines pack onto one element list; mixing point and face/line elements under one usemtl splits into one primitive per topology.
  • mg <group_number> [res] merging-group state-setting → preserved verbatim in Primitive::extras["obj:merging_group"]; a change mid-stream splits the primitive (mirrors s behaviour).
  • bevel on/off, c_interp on/off, d_interp on/off, and lod <level> display attributes → captured per-primitive in Primitive::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> → one Mesh per object directive (or a single mesh if the file has no o).
  • g name1 name2 … → multiple group names per line, captured in Primitive::extras["obj:groups"] and re-emitted on a single g line.
  • s 1 / s off / s 0 smoothing groups → preserved verbatim in Primitive::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> …] and usemtl <name> — each usemtl switch starts a fresh Primitive so a multi-material OBJ becomes a Mesh with N primitives, each with its own MaterialId.
  • Free-form geometry (vp parameter-space vertices, cstype, deg, curv, curv2, surf, parm, trim, hole, scrv, sp, end, plus superseded bzp / bsp patches) — captured verbatim into Scene3D::extras["obj:vp"] (1-based parallel vertex pool) and Scene3D::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 of curv 3D space curves, curv2 2D parameter-space trimming curves, and Bezier / B-spline / Cardinal / Taylor / basis-matrix surf surfaces is available via ObjDecoder::with_curve_tessellation(samples) (see the per-round notes below).

The companion MTL parser/serialiser handles:

  • Phong colours (Ka / Kd / Ks / Ke) → glTF base_color (from Kd) + emissive_factor (from Ke); Ka / Ks and the Ns exponent are stashed in Material::extras for round-trip.
  • Transparency (d dissolve / Tr = 1 - d) → AlphaMode::Blend
    • base_color.a. The d -halo factor orientation-dependent variant is detected and re-emitted via Material::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 (with g/b defaulting to r), Tf spectral file.rfl factorMaterial::extras["mtl:Tf:spectral"], and Tf xyz x y zMaterial::extras["mtl:Tf:xyz"].
  • Reflection sharpness (sharpness) and displacement / decal / reflection maps (dispmap_disp, decalmap_decal, reflmap_refl) round-trip via extras.
  • Typed reflection maps — refl -type sphere file and the six-face refl -type cube_top|cube_bottom|cube_front|cube_back|cube_left|cube_right cubemap. Sphere lands as Material::extras["mtl:refl:sphere"]; the six face lines bundle into Material::extras["mtl:refl:cube"] (one entry per face) so they don't collapse onto each other under last-write-wins; the encoder re-emits one refl -type <kind> ... file line per face / sphere in deterministic order.
  • Texture references (map_Kdbase_color_texture, map_Bumpnormal_texture, map_d etc.) emitted as ImageData::External { uri, mime: None } — the caller resolves paths against the OBJ file's directory. Leading -flag value option chunks (-blendu, -bm, -mm, -clamp, -imfchan, -o, -s, -t, -texres) are parsed out of the filename and surfaced via Material::extras["mtl:<map_name>:options"]; the encoder splices them back inline.
  • Wavefront-PBR extension (Pr roughness, Pm metallic, Pc clearcoat, Ps sheen, 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 original Copyright 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. Round 14: Taylor polynomial surf surface tessellation — the same with_curve_tessellation(samples) knob now also evaluates surf elements under a cstype taylor (or cstype rat taylor) header into a Topology::Triangles grid on the synthetic "obj:surfaces" mesh, via the bivariate tensor-product Horner-rule polynomial evaluation S(u, v) = Σ_i Σ_j c_{i,j} · u^i · v^j (spec §"Taylor"). Control points are the polynomial coefficients laid out row-major u-fastest (spec §"Surface vertex data — control points"); a single Taylor patch of declared degree deg degu degv needs exactly (degu + 1) × (degv + 1) coefficient vectors. The surf s0 s1 t0 t1 range supplies the global parameter clip; Taylor surfaces evaluate against the raw parameter values directly (not a normalised [0, 1] re-parameterisation). The spec note in §"Free-form curve/surface body statements" says the rational form "does not make sense for Taylor", so rat taylor routes to the same evaluator without per- vertex weight blending. Synthetic primitives carry the same obj:tessellated_surface / obj:surface_kind ("taylor") / 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. Round 14 (depth): cargo fuzz harness — fuzz/fuzz_targets/parse_obj.rs and fuzz/fuzz_targets/parse_mtl.rs drive attacker-controlled bytes through every public decoder entry point and assert panic-freedom (no panic / abort / debug-overflow / out-of-bounds index for any input). The first 180-second parse_obj run found two real crashes that are now fixed and pinned by regression tests in tests/fuzz_regressions.rs:

  • parse_face_vertex admitted an empty leading slot (so f /1 /2 and p /13 and l /1 /2 produced fv.v == 0 which then panicked on (fv.v - 1) as usize underflow downstream). Fix: require a non-empty position component at parse time so the fv.v >= 1 invariant holds end-to-end.
  • tessellate_surfaces allocated Vec::with_capacity(cols * rows) for the Bezier control-grid pool without bounding the product against the declared control-vertex count, so deg 111111 blew past available memory. Fix: checked_add / checked_mul on the grid extents and an early "expected != entry control-count" bail so the allocation never runs for mismatched grids. Same defence applied to the cstype bmatrix (n + 1) × (n + 1) basis-size check. Subsequent 180-second runs against parse_obj (corpus grew to ~8.8k discovered inputs) and parse_mtl (corpus grew to ~1.1k) finished without further crashes / OOM / timeouts. The fuzz subcrate's Cargo.lock is tracked for reproducible builds; transient fuzz/target/, fuzz/corpus/, and fuzz/artifacts/ paths sit on .gitignore. Round 182: basis-matrix surf surface tessellation — the same with_curve_tessellation(samples) knob now also evaluates surf elements under a cstype bmatrix (or cstype rat bmatrix) header into a Topology::Triangles grid on the synthetic "obj:surfaces" mesh, via the bivariate tensor-product polynomial S(u, v) = Σ_a Σ_b (Σ_p B_u[a][p] · u^p) (Σ_q B_v[b][q] · v^q) · c_{base_u + a, base_v + b} (spec §"Basis matrix", §"bmat u/v matrix", §"step stepu stepv"). Per-direction basis matrices come from bmat u / bmat v (row-major, column index varying fastest); per-direction segment strides come from step stepu stepv. The per-direction control-grid extent inverts the spec relation parm = (K − n) / s + 2 to K = (parm − 2) · s + n + 1, applied independently in u and v ("For surfaces, the above description applies independently to each parametric direction."). Multi-patch grids decompose into per-segment patch windows starting at (seg_u · stepu, seg_v · stepv), so the spec §"Examples" cubic Bezier-as-bmatrix surface single-patch case matches the equivalent cstype bezier patch sample-for-sample. The rat bmatrix qualifier routes to the same evaluator without per-vertex weight blending (matches the round-10 1D curve behaviour). Synthetic primitives carry the same obj:tessellated_surface / obj:surface_kind ("bmatrix") / 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 / bmat u / bmat v / step / parm u / parm v / surf / end block unchanged. Round 188: 2D trimming-curve (curv2) tessellation — the same with_curve_tessellation(samples) knob now also evaluates every curv2 directive (the parameter-space curve referenced by trim / hole / scrv, spec §"curv2") into a Topology::LineStrip polyline on a new synthetic mesh named "obj:curves2". A curv2 references vp parameter vertices (spec §"vp u v w") and lies in the 2D parameter space of the surface it trims, so each vp (u, v) is lifted into a flat [u, v, 0.0] control point and run through the same Bezier / B-spline / Cardinal / Taylor / basis-matrix evaluators the 3D curv path uses — the sampled x/y are the parameter-space coordinates, z stays 0.0. Unlike curv, a curv2 line carries no inline u0 u1; the B-spline evaluation window comes from the block's parm u knot vector. The optional 3rd vp coordinate is the rational weight (default 1.0; the vp 0.0 padding default reads back as 1.0 for the rational forms). Negative curv2 indices resolve relative-from-end against the vp pool (spec §"Special point" example curv2 -6 -5 …). Synthetic primitives carry the shared obj:tessellated_curve sentinel plus a obj:curve2 marker and the obj:curve_kind / obj:curve_degree / obj:curve_u_range / obj:curve_samples provenance; the encoder filters them out and replays the original cstype / curv2 / parm / end block unchanged from Scene3D::extras["obj:freeform_directives"].

The .mod binary form remains out of scope; multi-patch Bezier surface decomposition (Bezier carries no step stride) and full surface clipping against the trim / hole loops (the loops' curv2 curves now tessellate, but the surface mesh is not yet clipped to them) are the remaining gaps.

License

MIT. See LICENSE.