oxideav-mesh3d
Pure-Rust 3D scene + mesh typed model.
The shared data model that every OxideAV 3D-format crate
(oxideav-stl, oxideav-obj, oxideav-gltf, future
oxideav-fbx / oxideav-usd) decodes into and encodes from. The
type model is aligned with glTF 2.0 (Khronos KHR-public spec) as
the spec-stable common denominator: right-handed coordinates,
Y-up, -Z forward, metres, metallic-roughness PBR, xyzw quaternions.
Files coming from Z-up formats (STL/OBJ Wavefront) just set
[Scene3D::up_axis] to Axis::PosZ — the model stores the
orientation, no implicit re-projection happens.
Round 1 ships:
Scene3D— top-level container holdingVecs of nodes, meshes, materials, textures, skeletons, skins, animations, cameras, lights, plusup_axis/front_axis/unitmetadata and a free-formextras: HashMap<String, serde_json::Value>round-trip side-channel.Node+Transform { Matrix, Trs }with best-effort matrix↔TRS decompose.Mesh/Primitive/Topology(Triangles, TriangleStrip, TriangleFan, Lines, LineStrip, LineLoop, Points) /Indices(U16 or U32). Multi-channel UVs and vertex colours; optional skinning joint indices + weights.Material— full glTF 2.0 metallic-roughness PBR slots (base_color,metallic,roughness,normal,occlusion,emissive) plusAlphaMode { Opaque, Mask{cutoff}, Blend }anddouble_sided.Texture/ImageData { Embedded(VideoFrame), Source(Arc<dyn AssetSource>), External }/Samplerwith the usual mag/min filters and wrap modes. TheSourcevariant lets format crates pass a lazy reader through the type model without materialising aVec<u8>(round 2).Skeleton(joint nodes + inverse-bind matrices) +Skinbinding to a mesh.Animation/AnimationChannel/AnimationSampler/AnimationProperty { Translation, Rotation, Scale, MorphWeights }/Interpolation { Step, Linear, CubicSpline }.Camera { Perspective, Orthographic }andLight { Directional, Point, Spot }.Mesh3DDecoder/Mesh3DEncodertraits +Mesh3DRegistry(case-insensitive extension lookup) — mirrors the codec-registry pattern fromoxideav-core.
Round 2 adds (still pre-publish, BREAKING vs round 1):
AssetSourcetrait —Send + Sync + Debuglazy reference to a binary asset (texture, audio, anything blob-shaped).open()returns a streaming reader; optionalraw_storage()exposes the asset's stored bytes + a scheme identifier so a writer targeting the same scheme (USDZ → USDZ, GLB → GLB) can pass the payload through without re-encoding.RawStorage<'a> { scheme, bytes, uncompressed_size }andInMemoryAsset(trivial owning impl).- Audio surface —
AudioSource,AudioEmitter,SpatialAudio,AuralMode { SpatialNonAcoustic, SpatialAcoustic },DistanceModel { Linear, Inverse, Exponential }. Aligned with USDUsdMediaSpatialAudio+ glTFKHR_audio_emitter.Scene3Dgainsaudio_sources+audio_emittersarenas;Nodegainsaudio_emitter: Option<AudioEmitterId>. - BREAKING:
ImageData::Encoded { mime, bytes }removed in favour ofImageData::Source(Arc<dyn AssetSource>). Migration: wrap bytes inInMemoryAsset { mime, bytes }. TheTexture::from_encoded(mime, bytes)helper signature is unchanged — it now wraps internally.
No format support yet; sibling crates (oxideav-stl,
oxideav-obj, oxideav-gltf) plug in via Mesh3DRegistry once
this crate is published.
Round 3 lands a cross-format roundtrip suite that exercises the
Mesh3DRegistry surface against the four published siblings
(oxideav-stl 0.0, oxideav-obj 0.0, oxideav-gltf 0.0,
oxideav-usdz 0.0) consumed as [dev-dependencies]:
tests/cross_format_roundtrip.rs— 16 tests covering typed-fixture → encoder → decoder fidelity for STL binary, STL ASCII, OBJ, glTF GLB, glTF JSON, plus six format-to-format chains (stl ↔ obj, stl ↔ gltf, obj ↔ gltf). Side-channel checks confirm that OBJusemtlround-trips throughPrimitive::extras, glTF preservesbase_colorliterally, STL drops materials but keeps geometry, andImageData::Source(InMemoryAsset)survives a glTF JSONdata:URI inline. Encoder rejection ofLinestopology by STL is asserted as a typedErr, not a panic.tests/registry_lookup.rs— 18 tests for theMesh3DRegistryresolution surface after every sibling crate'sregister(&mut reg)helper has run: extension and format-id routes for every codec, case-insensitivity contract, overwrite semantics on repeatedregister_decoder/register_encodercalls, unknown-key behaviour, reverse lookup (decoder_extensions(format_id)), and theDefault::default()/new()parity.
Round 4 lands a 15-test extension to the cross-format matrix in
tests/extras_and_skinning_coverage.rs:
- Skinning data primitive-level —
Primitive::joints+Primitive::weightssurvive a glTF round-trip bit-exact (JOINTS_0 + WEIGHTS_0 accessor channels); STL + OBJ silently drop them. The scene-levelskins/skeletons/animationsarrays are not yet round-tripped by glTF 0.0.0 — two tests pin that gap so a future encoder upgrade flips them from "drops" to "survives" in the same commit. - Multi-primitive vertex pool dedup (OBJ) — two primitives
sharing four corners pool to four
vlines in the OBJ output; triangle count survives; glTF keeps the per-primitive partition distinct. - Multi-material binding — two primitives × two materials emit
two
usemtldirectives in OBJ; both names round-trip viaPrimitive::extras["obj:usemtl"]. glTF preserves per-primitivematerialindex. - Cross-format extras audit — pins
stl:sourceidempotence, STLup_axis = PosZ/unit = Millimetres, OBJup_axis = PosY/unit = Metres, gltf → OBJ scene-extras drop, and gltf primitive-extras JSON value preservation.
Round 5 lands two test extensions:
tests/multi_material_pool_stress.rs(12 tests) — five-material bindings, cross-mesh OBJ vertex-pool dedup, multi-mesh hierarchy, material aliasing. Confirms the OBJ encoder's global vertex pool collapses across separateMeshes (not just within one), the glTF encoder preserves five distinctmaterialindices on five primitives, and STL flattens hierarchy into a flat triangle list.tests/encoder_options_roundtrip.rs(18 tests) — pins every configuration knob the published 0.0.0 sibling crates expose:StlEncoderconstructor parity (new_binary/new(StlFormat::Binary)/default) +format()getter +solidand 80-byte-header byte markers,ObjEncoder::with_mtl_basenamedirective injectionobj:mtllibsround-trip intoScene3D::extras,MtlEncoder↔parse_mtl/serialize_mtlparity,GltfEncoderflavour selection (new/with_output(Glb)/default()),json_encoder()helper parity withwith_output(JsonEmbedded). Byte-equality is not asserted on the glTF side because the encoder serialises aHashMap(per-primitiveattributes) whose iteration order varies per process invocation; we test flavour-id parity + decode-equivalence instead.
Round 6 lands typed morph-target fields (BREAKING, pre-v0.1):
MorphTarget { position, normal, tangent }— typed delta-buffer struct for one named morph pose per glTF 2.0 §3.7.2.2. Each slot isOption<Vec<[f32; 3]>>(the TANGENT slot is xyz-only — the base TANGENT's handednesswis not morphed per spec).Primitive::targets: Vec<MorphTarget>for the per-pose roster.Mesh::weights: Vec<f32>for the static default morph blend (anAnimationProperty::MorphWeightschannel overrides at runtime).- New builder helper
Mesh::with_weights. The forward-compatible construction style isPrimitive::new(Topology)/Mesh::new(name)+with_*builders + per-field assignment; literalPrimitive { … }/Mesh { … }construction still compiles in this round but must populate the two new fields.#[non_exhaustive]is deferred to round 7.
Sketch:
use ;
let mut prim = new;
prim.positions = vec!;
prim.normals = Some;
// One morph target ("smile") with POSITION + NORMAL deltas.
prim.targets.push;
let mesh = new
.with_primitive
.with_weights; // static blend: target 0 disabled
Round 10 lands the typed morph-blend evaluator
(tests/morph_apply.rs, 20 tests):
Primitive::apply_morph_weights(weights: &[f32]) -> MorphedAttributes— pure-Rust evaluation of the glTF 2.0 §3.7.2.2 morph formulamorphed[k] = base[k] + Σ weights[i] * targets[i].ATTR[k]over the three typed slots (POSITION,NORMAL,TANGENT). The base attribute presence drives output presence (a target slot named when the base is absent is silently dropped per spec line 3586). Tangent handednesswis preserved verbatim per spec line 3616 (delta is xyz-only). Missing or excess weights default to zero per spec line 3697; buffer-length mismatches apply the prefix and leave the remainder untouched. Empty weights / no targets short- circuit to a verbatim base clone.MorphedAttributes { positions, normals, tangents }—Clone + Debug + PartialEqoutput struct re-exported from the crate root.
Round 97 lands strip/fan → triangle-list de-stripping
(tests/destrip.rs, 25 tests):
Primitive::triangle_indices(&self) -> Vec<[u32; 3]>— expands the primitive's topology into a flat list of triangle vertex-index triples following the OpenGL/glTF primitive-assembly rules.TriangleStripapplies the alternating-winding rule (odd-numbered triangles swap their last two vertices so front-facing winding stays consistent);TriangleFanshares the anchor vertexv[0];Trianglesreturns its triples verbatim (dropping a trailing incomplete triple). When an index buffer is present each entry is dereferenced so the result indexes the vertex pool (not the index buffer),U16/U32both widened tou32. Non-triangle topologies (lines/points) return an empty list. Output count equalstriangle_count()for triangle topologies.Primitive::to_triangle_list(&self) -> Primitive— materialises an equivalentTopology::Trianglesprimitive with a freshU32index buffer (the flattenedtriangle_indices). Attribute buffers,material, morphtargets, andextrasare carried over verbatim — only connectivity is rewritten. STL (list-only) and OBJ encoders that can't emit strips/fans natively consume this to flatten glTF/FBX strip primitives.
Round 105 lands per-vertex MikkTSpace-style tangent-space basis
recomputation (tests/compute_tangents.rs, 28 tests):
Primitive::compute_tangents(&self, uv_set: usize) -> Option<Vec<[f32; 4]>>— derives per-vertex tangents from positions, the selected UV channel (uv_setindexesPrimitive::uvs), and the existing per-vertex normals. Each[f32; 4]isxyz= unit tangent T plusw= ±1.0 handedness, so the renderer reconstructs the bitangent asB = w * (N × T)exactly the way glTF 2.0 §3.7.2.1 / the MikkTSpace contract specifies theTANGENTaccessor. The math is the closed formT = (Δv2·E1 − Δv1·E2) / det,B = (−Δu2·E1 + Δu1·E2) / detobtained by inverting the per-triangle 2×2 UV-delta linear system (Lengyel, "Computing Tangent Space Basis Vectors for an Arbitrary Mesh" 2001; the same derivation appears in the Normal Mapping chapter of Akenine-Möller, Haines & Hoffman, Real-Time Rendering). Per-triangle contributions are accumulated with the numerator scaled bysign(det)(so the area weighting is by unsigned UV area|det|/2, while a mirrored UV chart still pulls T in the +U surface direction); per-vertex sums are then Gram-Schmidt orthonormalised against N, and handedness is recovered fromsign((N × T') · B_sum). Topology integration goes throughtriangle_indices, soTriangles/TriangleStrip/TriangleFanall feed in. ReturnsNonewhen prerequisites are missing (no normals, UV set absent, length mismatch); otherwise output length always equalspositions.len(), with unreferenced / degenerate / T-parallel-to-N vertices falling back to[1.0, 0.0, 0.0, 1.0]so the result is always renderable. Pure (noselfmutation) — assign toPrimitive::tangents. This is the recompute step a format decoder runs when the wire stream omits tangents (OBJ has no native tangent channel, glTF withoutTANGENT).
Round 101 lands area-weighted per-vertex normal recomputation
(tests/compute_normals.rs, 22 tests):
Primitive::compute_normals(&self) -> Vec<[f32; 3]>— recomputes smooth per-vertex normals from the primitive's triangle connectivity (the de-stripped list fromtriangle_indices, soTriangles/TriangleStrip/TriangleFanall feed in correctly). Each triangle's un-normalised face normal is the edge cross product(P[b]-P[a]) × (P[c]-P[a]); because its magnitude is twice the triangle area, accumulating it into each incident vertex and normalising the sum gives the area-weighted average of the neighbouring face normals — the textbook smooth-shading recomputation (Gouraud 1971; Foley, van Dam et al., Computer Graphics: Principles and Practice). CCW = front-facing (right-handed, glTF-aligned). Output length always equalspositions.len(). Unreferenced vertices, vertices touched only by degenerate (collinear/coincident) faces, out-of-range index entries, and NaN-producing faces fall back to[0, 0, 1]rather than a zero vector or a panic; non-triangle topologies produce an all-fallback buffer. Pure (noselfmutation) — assign the result toPrimitive::normalsto store. This is the recompute step a format decoder runs when the wire stream omits normals (OBJ withoutvn, glTF withoutNORMAL).
Round 155 lands mesh-validity invariants — degenerate-triangle
detection + edge-manifold classification (tests/mesh_validity.rs,
34 tests):
Primitive::degenerate_triangles(&self) -> Vec<usize>— returns the indices intotriangle_indices()for triangles whose three corners are collinear or coincident in 3D (i.e. zero-area). Detection is the same|E1 × E2| == 0test thatcompute_normals/compute_tangentsalready use to silently drop bad faces; this is the detection-only counterpart so a validator can warn or a repair pass can prune them. No epsilon thresholding (a triangle that is almost collinear within float precision but produces a non-zero cross product is reported valid — proximity-based pruning is a separate lossy op, out of scope). Out-of-range index entries and NaN-producing faces are also reported. Non-triangle topologies (lines/points) return an emptyVec. Pure;O(triangle_count).Primitive::edge_manifold_report(&self) -> EdgeManifoldReport+EdgeManifoldReport { total_edge_count, boundary_edge_count, manifold_interior_edge_count, non_manifold_edge_count, max_edge_use }is_closed_manifold(&self) -> bool— classifies every undirected triangle edge by use count:1(boundary — hole/crack/open rim),2(manifold-interior — clean two-manifold seam),≥ 3(non-manifold — T-junction / book-spine / feather). A closed two-manifold mesh hasboundary_edge_count == 0 && non_manifold_edge_count == 0, which is exactly the STL spec's vertex-to-vertex rule ("each triangle must share two vertices with each of its adjacent triangles" — Fabbers / Stratasys 1989) and the classical solid-printable condition. Triangles with a duplicate corner index or an out-of-range entry are excluded whole (their bogus edges don't pollute neighbour counts). Topology comparison is by vertex index, not by 3D position — runweld_verticesfirst if positional duplicates should merge before counting. Pure;O(triangle_count).
Round 118 lands coincident-vertex welding / index de-duplication
(tests/weld_vertices.rs, 29 tests):
Primitive::weld_vertices(&self) -> Primitive— merges bit-identical rendering vertices into a shared pool and returns an equivalent indexed primitive whose attribute buffers hold only the distinct vertices, with the index buffer rewritten to reference the deduplicated pool. This is the inverse of attribute explosion: a decoder for a non-shared format (binary STL stores three fresh vertices per facet; an OBJfcorner is a distinct rendering vertex) produces a vertex soup where coincident corners are duplicated; welding collapses them so the GPU's post-transform vertex cache can reuse a shared vertex. Two source vertices merge iff every present attribute slot is bit-identical —positions, eachNORMAL/TANGENT, every UV and colour set, thejoints/weightsquads, and every [MorphTarget] delta — because one index in an indexed draw selects one tuple across all attribute streams at once (so a UV seam or a hard-edge normal correctly stays split). Float keys are exact (bit pattern), with-0.0folded to+0.0and everyNaNcanonicalised so dedup stays deterministic; no epsilon tolerance (proximity welding is a separate lossy op, out of scope).topologyis preserved verbatim (valid for triangles/strips/fans/lines/points, not just triangle lists); an existing index buffer is remapped through the dedup table (out-of-range entries dropped, not panicked), a non-indexed input materialises its implicit order. Index width follows glTF promotion:U16while the pool is≤ 65 536entries, elseU32. The pool is gathered in first-seen order so the result is reproducible;material/targetsshape /extrascarry over. Pure (noselfmutation). A position-only cube soup welds 36 → 8 corners; a flat-shaded cube (normal in the identity) welds 36 → 24.
Round 182 lands the signed-volume reduction (tests/volume.rs,
30 tests):
Primitive::signed_volume(&self) -> f64— divergence-theorem reductionV = (1/6) Σ (P_a · (P_b × P_c))over the primitive's triangle tessellation, in the unit-cubed ofPrimitive::positions. The derivation comes from substituting the radial fieldF = x/3(∇ · F = 1) into Gauss's theorem∫∫∫_V (∇·F) dV = ∫∫_S F · dS, which collapses each triangle's contribution to(P_a · (P_b × P_c)) / 6— geometrically, each triangle plus the origin forms a tetrahedron whose signed volume is that scalar triple product, and the origin-coincident faces cancel pairwise for a closed mesh, leaving only the boundary shells (Cha & Chen, "Efficient feature extraction for 2D/3D objects in mesh representation", ICIP 2001; the closed form also appears in any introductory divergence-theorem treatment, e.g. Marsden & Tromba, Vector Calculus). The cross-product machinery is the same onecompute_normals/surface_areaalready share —signed_volumeadds one scalar dot per triangle. Sign follows the winding convention: CCW-from-outside (right-handed, glTF-aligned) is positive; an inside-out mesh produces the same magnitude with the opposite sign. Topology integration goes throughtriangle_indices, soTriangles/TriangleStrip(alternating winding) /TriangleFanall feed in correctly; non-triangle topologies (lines/points) contribute 0.0. Accumulator isf64so million-triangle meshes don't drift underf32summation. Degenerate (collinear/coincident corners), NaN- or Inf-producing faces, and out-of-range index entries all contribute 0.0 — the result is always finite. Translation-invariant for a closed surface (origin-coincident tetra contributions cancel). Only physically meaningful for a closed two-manifold (seeis_closed_manifold); arithmetically well-defined regardless. Pure; costO(triangle_count).Primitive::volume(&self) -> f64— unsigned|signed_volume()|, robust to inside-out winding.Mesh::signed_volume(&self) -> f64/Mesh::volume(&self) -> f64— sum across every contained primitive (mesh-local, no transforms / skin pose / morph deltas).Mesh::volumeis|Σ signed|, notΣ |signed|(single-shell assumption); for a multi-shell mesh, sum each primitive'svolume()separately.Scene3D::signed_volume(&self) -> f64/Scene3D::volume(&self) -> f64— sum across every mesh in the scene. Walks meshes once, not node instances — a mesh instanced by two nodes contributes its volume once. For a transform-aware total, walkworld_node_transformsand apply each node's scale's signed determinant per primitive instance (a negative scale flips winding and thus flips the sign).
Round 175 lands the surface-area reduction (tests/surface_area.rs,
30 tests):
Primitive::surface_area(&self) -> f64— total area of the primitive's triangle tessellation in the unit-squared ofPrimitive::positions(matching the parentScene3D::unit). Each triangle's area is the half cross-product magnitude|E1 × E2| / 2(Marsden & Tromba, Vector Calculus — the cross-product magnitude is the parallelogram-area definition; a triangle occupies half of that parallelogram). The sameE1 × E2already drivescompute_normals(its magnitude is twice the triangle area, which is exactly why summing the un-normalised face normal into each vertex automatically area-weights smooth shading);surface_areareuses the edge-cross machinery and divides by two. Topology integration goes throughtriangle_indices, soTriangles/TriangleStrip(alternating winding) /TriangleFanall feed in correctly; non-triangle topologies (lines/points) contribute 0.0. Accumulator isf64so million-triangle meshes don't drift underf32summation. Degenerate (collinear/coincident corners), NaN- or Inf-producing faces, and out-of-range index entries all contribute 0.0 — the result is always finite. Pure; costO(triangle_count).Mesh::surface_area(&self) -> f64— sum across every contained primitive (mesh-local, no transforms / skin pose / morph deltas).Scene3D::surface_area(&self) -> f64— sum across every mesh in the scene. Walks meshes once, not node instances — a mesh instanced by two nodes contributes its area once. For a transform-aware total, walkworld_node_transformsand apply the per-node scale determinant per primitive instance.
Round 189 lands the scene-graph world-transform snapshot
(tests/world_transforms.rs, 21 tests):
Scene3D::world_node_transforms(&self) -> Vec<Option<[[f32; 4]; 4]>>— depth-first walk over the [Scene3D::roots] forest, returning the composed world-space 4x4 matrix for every reachable node (andNonefor detached nodes). The output vector is indexed byNodeId.0so a caller can look up a node's world matrix in O(1) without re-walking the ancestor chain. Each slot is a row-major column-vector matrix taking a position in the node's local frame to world space — matching the convention used byTransform::to_matrix,BoundingBox::transform, and the rest of the crate. The traversal mirrors the iterative DFS in [Scene3D::bounding_box]: roots are visited inroots-order, children in source order, cycles are guarded (a node revisited via a back-edge keeps its first-encountered matrix), and a shared child node (listed under two parents) resolves to the first parent's chain (a deterministic single-resolution policy; per-instance world matrices need a separate instance-list side-channel). Out-of-rangeNodeIdentries inroots/childrenare silently skipped — the walk is total. Cost isO(nodes.len() + total_children); allocates oneVec<Option<...>>of lengthnodes.len()plus the DFS stack. TheScene3D::{surface_area, signed_volume, volume}docs already referenced this helper as the entry point for transform-aware aggregate metrics — multiply each primitive's localsurface_areaby|det(R · diag(s))|of the upper-left 3x3 or itssigned_volumebysign(det) · |det|to obtain the transform-folded total. Static scene-graph only — skin pose deformation, animation channels, camera matrices, and unit-axis conversion are not folded in (those are layered above this primitive).
Round 192 lands the transform-aware sibling reductions to
world_node_transforms (tests/world_metrics.rs, 35 tests):
Scene3D::world_surface_area(&self) -> f64— same depth-first walk asworld_node_transforms(and the same first-parent shared-instance resolution), accumulating each reachable instance's post-transform triangle area. Mesh resources not reachable from any root contribute 0; a mesh instanced under N reachable nodes contributes N times. Per-triangle math: under the upper-left 3x3 of the world matrix, a triangle's area scales by|(M_3·E1) × (M_3·E2)| / |E1 × E2|, which collapses tos²under uniform scalesbut is orientation-sensitive under a non-uniform diagonal scale — hence the per-triangle walk rather than a single det-based scale. Pure;O(reachable_nodes + Σ triangle_count_per_reachable_mesh).Scene3D::world_signed_volume(&self) -> f64— same walk, collapses toΣ det(M_3x3) · V_localfor closed two-manifold meshes (the open-mesh boundary terms vanish under the same origin-cancellation argument that makesPrimitive::signed_volumetranslation-invariant for a closed surface). A uniform scalesproduces factors³; a single-axis mirror produces-1, correctly flipping the enclosed-volume sign because the triangle winding flips with the mirror. Mesh resources not reachable contribute 0; per-instance contributions sum signed.Scene3D::world_volume(&self) -> f64— unsigned|world_signed_volume()|. Same|Σ signed|(notΣ |signed|) caveat asScene3D::volume/Mesh::volume: a scene combining a mirrored and an unmirrored instance of the same closed mesh cancels to ~0 in the unsigned sum.Primitive::world_surface_area(&self, world: [[f32; 4]; 4]) -> f64— per-primitive helper underlying the scene-level walk; matchesPrimitive::surface_area's topology / degenerate-triangle / out-of-range / NaN-skipping contract and is finite for any finite input.
Round 199 lands ray-mesh / ray-AABB intersection primitives
(tests/ray_intersect.rs, 46 tests):
Ray { origin, direction }value type +Ray::point_at(t)— directed half-line with non-unit-requireddirection. Re-exported from the crate root.RayHit { t, triangle_index, barycentric, front_face }— closest- hit record.triangle_indexindexes [Primitive::triangle_indices];barycentricis[w, u, v]withw = 1 - u - vso the hit point reconstructs asw * P0 + u * P1 + v * P2;front_faceis the CCW-from-outside front (right-handed, glTF-aligned), i.e.D · N < 0for the triangle's outward normalN = E1 × E2.ray::intersect_triangle(ray, p0, p1, p2, t_max)— the Möller-Trumbore closed form (Möller & Trumbore, "Fast, Minimum Storage Ray-Triangle Intersection", Journal of Graphics Tools 2(1), 1997). For edgesE1 = P1 - P0,E2 = P2 - P0andP = D × E2, the determinantdet = E1 · Pis the Cramer's-rule denominator; barycentric(u, v)and ray parametertfall out asu = (S · P) / det,v = (D · Q) / det,t = (E2 · Q) / detwithS = O - P0,Q = S × E1. Sign ofdetcarries the front/back side (sincedet = -D · N). The same cross-product machinery already drivescompute_normals/surface_area/signed_volume. Degenerate triangles (|det| < 1e-8, ray parallel to plane or zero-area face), NaN/Inf math, behind-origin hits, and out-of-triangle barycentrics all returnNone— matching the silent-skip robustness contract of the existing reductions.ray::intersect_aabb(ray, min, max, t_max)— slab method (Kay & Kajiya, "Ray Tracing Complex Scenes", SIGGRAPH 1986). Per-axis entry/exit distances(min − O) / D,(max − O) / Dare intersected across all three axes; an axis-parallel ray (|D[axis]| < 1e-30) passes through that axis's test when origin is inside the slab and immediately misses when outside. Returns(t_enter, t_exit)clamped to[0, t_max]; origin-inside-box reportst_enter = 0. NaN/Inf inputs miss.BoundingBox::intersect_ray(ray, t_max)— thin wrapper aroundintersect_aabbso a BVH traverser calls one method per box on the existing AABB value type. Cheap O(1) early-out before recursing into per-primitiveintersect_ray.Primitive::intersect_ray(&self, ray, t_max) -> Option<RayHit>— brute-force walk overtriangle_indices()callingintersect_triangle, keeping the smallestt. Topology integration goes through the existing de-stripping helper, soTriangles/TriangleStrip(alternating winding honoured) /TriangleFanall feed in; non-triangle topologies (lines/points) returnNone. Out-of-range index entries / degenerate faces / NaN positions are silently skipped (same robustness contract ascompute_normals/surface_area/signed_volume). Pure;O(triangle_count). Designed as the BVH-leaf inner loop — spatial acceleration is the caller's concern, layered by checkingBoundingBox::intersect_raybefore recursing.Primitive::any_ray_intersection(&self, ray, t_max) -> bool— shadow-ray early-exit; returns on the first hit found without tracking the closest one. Same topology / robustness contract.Mesh::intersect_ray(&self, ray, t_max) -> Option<(usize, RayHit)>— closest-hit across every contained primitive, shrinking thet_maxbound as hits land. Returns the primitive index alongside the hit record. Mesh-local space — node-graph world transforms are not folded in (transform the ray into mesh-local space by inverse-multiplying viaScene3D::world_node_transformsbefore calling, or iterate one mesh instance at a time).
Round 11 candidates
- USDZ row of the cross-format matrix — needs
oxideav-usdzto publish 0.0.1 (which lands its first encoder); 0.0.0 ships a decoder only. Once the encoder is on crates.io, add stl→usdz, obj→usdz, gltf→usdz pairs tocross_format_roundtrip.rs. - glTF consumer migration off the
__morph_targets/__mesh_weightsextras sentinels onto the new typedPrimitive::targets/Mesh::weightsfields (the round-6 typed surface lands here; gltf encoder/decoder consumes it once mesh3d publishes 0.0.2). - glTF scene-level
skins+skeletons+animationsarray serialisation — the round-4 pinning tests will flip from "drops" to "survives" in the same commit. Producer-side change inoxideav-gltf; consumer-side flip-and-republish here. - KHR extension surface (
KHR_materials_emissive_strength,KHR_materials_unlit,KHR_lights_punctual,KHR_audio_emitter) on top of the existing glTF round-trip. - Per-face material binding via
UsdGeomSubset(USDZ decoder side) + glTF KHR_materials_variants for per-LOD or per-instance swapping.
Standalone build
oxideav-core is gated behind the default-on registry cargo
feature. Drop the framework dependency entirely with:
= { = "0.0", = false }
The typed model and trait definitions stay available — only the
embedded VideoFrame / AudioFrame variants
(ImageData::Embedded / AudioData::Embedded) disappear, and the
Error / Result aliases resolve to a crate-local enum instead
of oxideav_core::Error. AssetSource::open() returns a
crate-local ReadSeek trait alias with the same shape as
oxideav_core::ReadSeek, so the trait surface is identical
either way.
License
MIT — see LICENSE.