use oxideav_mesh3d::{Mesh3DDecoder, Topology};
use oxideav_obj::{ObjDecoder, obj};
const BILINEAR_SURF: &str = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
cstype bezier
deg 1 1
surf 0.0 1.0 0.0 1.0 1 2 3 4
parm u 0.0 1.0
parm v 0.0 1.0
end
";
#[test]
fn default_decoder_does_not_tessellate_surfaces() {
let bare = ObjDecoder::new().decode(BILINEAR_SURF.as_bytes()).unwrap();
assert!(
bare.meshes.is_empty(),
"default decoder must not synthesise surface meshes"
);
}
#[test]
fn bilinear_surface_tessellates_into_a_triangle_grid() {
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(BILINEAR_SURF.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1, "one synthetic surface mesh expected");
let mesh = &scene.meshes[0];
assert_eq!(mesh.name.as_deref(), Some("obj:surfaces"));
assert_eq!(mesh.primitives.len(), 1);
let prim = &mesh.primitives[0];
assert_eq!(prim.topology, Topology::Triangles);
assert_eq!(prim.positions.len(), 25, "(samples + 1)^2 lattice vertices");
let indices = prim.indices.as_ref().expect("triangle indices");
assert_eq!(indices.len(), 96, "4*4 cells * 2 tris * 3 verts");
for p in &prim.positions {
assert!(p[2].abs() < 1e-5, "vertex off the z=0 plane: {p:?}");
}
let stride = 5usize;
let c00 = prim.positions[0]; let c10 = prim.positions[stride - 1]; let c01 = prim.positions[(stride - 1) * stride]; let c11 = prim.positions[stride * stride - 1]; assert!((c00[0] - 0.0).abs() < 1e-5 && (c00[1] - 0.0).abs() < 1e-5);
assert!((c10[0] - 1.0).abs() < 1e-5 && (c10[1] - 0.0).abs() < 1e-5);
assert!((c01[0] - 0.0).abs() < 1e-5 && (c01[1] - 1.0).abs() < 1e-5);
assert!((c11[0] - 1.0).abs() < 1e-5 && (c11[1] - 1.0).abs() < 1e-5);
let centre = prim.positions[2 * stride + 2];
assert!(
(centre[0] - 0.5).abs() < 1e-5 && (centre[1] - 0.5).abs() < 1e-5,
"centre sample mismatch: {centre:?}"
);
assert_eq!(
prim.extras
.get("obj:tessellated_curve")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
prim.extras
.get("obj:tessellated_surface")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
prim.extras.get("obj:surface_kind").and_then(|v| v.as_str()),
Some("bezier")
);
let deg = prim
.extras
.get("obj:surface_degree")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(deg[0].as_u64(), Some(1));
assert_eq!(deg[1].as_u64(), Some(1));
assert_eq!(
prim.extras
.get("obj:surface_samples")
.and_then(|v| v.as_u64()),
Some(4)
);
let u_range = prim
.extras
.get("obj:surface_u_range")
.and_then(|v| v.as_array())
.unwrap();
assert!((u_range[0].as_f64().unwrap() - 0.0).abs() < 1e-6);
assert!((u_range[1].as_f64().unwrap() - 1.0).abs() < 1e-6);
let v_range = prim
.extras
.get("obj:surface_v_range")
.and_then(|v| v.as_array())
.unwrap();
assert!((v_range[0].as_f64().unwrap() - 0.0).abs() < 1e-6);
assert!((v_range[1].as_f64().unwrap() - 1.0).abs() < 1e-6);
}
const SADDLE_SURF: &str = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 1.0
cstype bezier
deg 1 1
surf 0.0 1.0 0.0 1.0 1 2 3 4
parm u 0.0 1.0
parm v 0.0 1.0
end
";
#[test]
fn non_planar_bilinear_centre_is_corner_average() {
let scene = ObjDecoder::new()
.with_curve_tessellation(2)
.decode(SADDLE_SURF.as_bytes())
.unwrap();
let prim = &scene.meshes[0].primitives[0];
let centre = prim.positions[4];
assert!(
(centre[2] - 0.25).abs() < 1e-5,
"saddle centre z mismatch: {centre:?}"
);
}
const BICUBIC_SURF: &str = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 2.0 0.0 0.0
v 3.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 1.0
v 2.0 1.0 1.0
v 3.0 1.0 0.0
v 0.0 2.0 0.0
v 1.0 2.0 1.0
v 2.0 2.0 1.0
v 3.0 2.0 0.0
v 0.0 3.0 0.0
v 1.0 3.0 0.0
v 2.0 3.0 0.0
v 3.0 3.0 0.0
cstype bezier
deg 3 3
surf 0.0 1.0 0.0 1.0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
parm u 0.0 1.0
parm v 0.0 1.0
end
";
#[test]
fn bicubic_surface_interpolates_corners_and_bulges_interior() {
let scene = ObjDecoder::new()
.with_curve_tessellation(8)
.decode(BICUBIC_SURF.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1);
let prim = &scene.meshes[0].primitives[0];
assert_eq!(prim.topology, Topology::Triangles);
assert_eq!(prim.positions.len(), 81);
assert_eq!(prim.indices.as_ref().unwrap().len(), 384);
let stride = 9usize;
let c00 = prim.positions[0]; let c10 = prim.positions[stride - 1]; let c01 = prim.positions[(stride - 1) * stride]; let c11 = prim.positions[stride * stride - 1]; assert!((c00[0]).abs() < 1e-4 && (c00[1]).abs() < 1e-4 && (c00[2]).abs() < 1e-4);
assert!((c10[0] - 3.0).abs() < 1e-4 && (c10[1]).abs() < 1e-4 && (c10[2]).abs() < 1e-4);
assert!((c01[0]).abs() < 1e-4 && (c01[1] - 3.0).abs() < 1e-4 && (c01[2]).abs() < 1e-4);
assert!((c11[0] - 3.0).abs() < 1e-4 && (c11[1] - 3.0).abs() < 1e-4 && (c11[2]).abs() < 1e-4);
for sv in 0..stride {
let edge = prim.positions[sv * stride];
assert!(
edge[2].abs() < 1e-4,
"u=0 boundary should stay flat, got {edge:?}"
);
}
let centre = prim.positions[4 * stride + 4];
assert!(
centre[2] > 0.1,
"interior should bulge above z=0, got {centre:?}"
);
let deg = prim
.extras
.get("obj:surface_degree")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(deg[0].as_u64(), Some(3));
assert_eq!(deg[1].as_u64(), Some(3));
}
const RAT_SURF: &str = "\
v 0.0 0.0 0.0 1.0
v 1.0 0.0 0.0 1.0
v 0.0 1.0 0.0 1.0
v 1.0 1.0 0.0 3.0
cstype rat bezier
deg 1 1
surf 0.0 1.0 0.0 1.0 1 2 3 4
parm u 0.0 1.0
parm v 0.0 1.0
end
";
#[test]
fn rational_surface_centre_pulls_toward_heavy_corner() {
let scene = ObjDecoder::new()
.with_curve_tessellation(2)
.decode(RAT_SURF.as_bytes())
.unwrap();
let prim = &scene.meshes[0].primitives[0];
assert_eq!(
prim.extras.get("obj:surface_kind").and_then(|v| v.as_str()),
Some("rat_bezier")
);
let centre = prim.positions[4];
assert!(
(centre[0] - (1.0 / 1.5)).abs() < 1e-4,
"rational centre x = {centre:?}, expected ≈ 0.6667"
);
assert!(
(centre[1] - (1.0 / 1.5)).abs() < 1e-4,
"rational centre y = {centre:?}, expected ≈ 0.6667"
);
}
#[test]
fn surf_control_points_accept_slash_references_and_negatives() {
let text = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
vt 0.0 0.0
vt 1.0 0.0
vt 0.0 1.0
vt 1.0 1.0
cstype bezier
deg 1 1
surf 0.0 1.0 0.0 1.0 -4/1 -3/2 -2/3 -1/4
parm u 0.0 1.0
parm v 0.0 1.0
end
";
let scene = ObjDecoder::new()
.with_curve_tessellation(2)
.decode(text.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1);
let prim = &scene.meshes[0].primitives[0];
assert_eq!(prim.positions.len(), 9);
assert!((prim.positions[0][0]).abs() < 1e-5 && (prim.positions[0][1]).abs() < 1e-5);
}
#[test]
fn tessellated_surface_is_not_emitted_as_v_lines_by_encoder() {
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(BILINEAR_SURF.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1, "synthetic surface mesh present");
let bytes = obj::serialize_obj(&scene, None).unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
let v_lines = text.lines().filter(|l| l.starts_with("v ")).count();
assert!(
v_lines <= 4,
"tessellation samples leaked as `v` lines; got {v_lines}:\n{text}"
);
assert!(
!text.contains("o obj:surfaces"),
"synthetic surface mesh must not be re-emitted as a polygonal `o` block"
);
for keyword in [
"cstype bezier",
"deg 1 1",
"surf 0",
"parm u 0",
"parm v 0",
"end",
] {
assert!(
text.lines().any(|l| l.starts_with(keyword)),
"missing `{keyword}` line in:\n{text}"
);
}
}
#[test]
fn unrecognised_surface_basis_is_left_captured_only() {
let text = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
cstype mystery_basis
deg 1 1
surf 0.0 1.0 0.0 1.0 1 2 3 4
parm u 0.0 1.0
parm v 0.0 1.0
end
";
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(text.as_bytes())
.unwrap();
assert!(
scene.meshes.is_empty(),
"unrecognised `cstype` surfaces stay captured-only; no synthetic mesh expected"
);
assert!(scene.extras.contains_key("obj:freeform_directives"));
}
#[test]
fn malformed_surf_with_wrong_control_count_is_skipped() {
let text = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
cstype bezier
deg 2 2
surf 0.0 1.0 0.0 1.0 1 2 3 4
parm u 0.0 1.0
parm v 0.0 1.0
end
";
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(text.as_bytes())
.unwrap();
assert!(
scene.meshes.is_empty(),
"control-point count mismatch should skip tessellation"
);
}
const BSPLINE_BILINEAR: &str = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
cstype bspline
deg 1 1
surf 0.0 1.0 0.0 1.0 1 2 3 4
parm u 0.0 0.0 1.0 1.0
parm v 0.0 0.0 1.0 1.0
end
";
#[test]
fn default_decoder_does_not_tessellate_bspline_surfaces() {
let bare = ObjDecoder::new()
.decode(BSPLINE_BILINEAR.as_bytes())
.unwrap();
assert!(
bare.meshes.is_empty(),
"default decoder must not synthesise B-spline surface meshes"
);
}
#[test]
fn bspline_bilinear_surface_tessellates_into_a_flat_grid() {
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(BSPLINE_BILINEAR.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1, "one synthetic surface mesh expected");
let mesh = &scene.meshes[0];
assert_eq!(mesh.name.as_deref(), Some("obj:surfaces"));
assert_eq!(mesh.primitives.len(), 1);
let prim = &mesh.primitives[0];
assert_eq!(prim.topology, Topology::Triangles);
assert_eq!(prim.positions.len(), 25, "(samples + 1)^2 lattice vertices");
assert_eq!(prim.indices.as_ref().unwrap().len(), 96);
for p in &prim.positions {
assert!(p[2].abs() < 1e-5, "vertex off the z=0 plane: {p:?}");
}
let stride = 5usize;
let c00 = prim.positions[0];
let c10 = prim.positions[stride - 1];
let c01 = prim.positions[(stride - 1) * stride];
let c11 = prim.positions[stride * stride - 1];
assert!((c00[0]).abs() < 1e-5 && (c00[1]).abs() < 1e-5);
assert!((c10[0] - 1.0).abs() < 1e-5 && (c10[1]).abs() < 1e-5);
assert!((c01[0]).abs() < 1e-5 && (c01[1] - 1.0).abs() < 1e-5);
assert!((c11[0] - 1.0).abs() < 1e-5 && (c11[1] - 1.0).abs() < 1e-5);
let centre = prim.positions[2 * stride + 2];
assert!(
(centre[0] - 0.5).abs() < 1e-5 && (centre[1] - 0.5).abs() < 1e-5,
"centre sample mismatch: {centre:?}"
);
assert_eq!(
prim.extras
.get("obj:tessellated_curve")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
prim.extras
.get("obj:tessellated_surface")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
prim.extras.get("obj:surface_kind").and_then(|v| v.as_str()),
Some("bspline")
);
let deg = prim
.extras
.get("obj:surface_degree")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(deg[0].as_u64(), Some(1));
assert_eq!(deg[1].as_u64(), Some(1));
}
const BSPLINE_QUADRATIC_CLAMPED: &str = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.6
v 2.0 0.0 0.0
v 0.0 1.0 0.6
v 1.0 1.0 1.4
v 2.0 1.0 0.6
v 0.0 2.0 0.0
v 1.0 2.0 0.6
v 2.0 2.0 0.0
cstype bspline
deg 2 2
surf 0.0 1.0 0.0 1.0 1 2 3 4 5 6 7 8 9
parm u 0.0 0.0 0.0 1.0 1.0 1.0
parm v 0.0 0.0 0.0 1.0 1.0 1.0
end
";
#[test]
fn clamped_quadratic_bspline_matches_quadratic_bezier() {
let ctrl: [[f32; 3]; 9] = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.6],
[2.0, 0.0, 0.0],
[0.0, 1.0, 0.6],
[1.0, 1.0, 1.4],
[2.0, 1.0, 0.6],
[0.0, 2.0, 0.0],
[1.0, 2.0, 0.6],
[2.0, 2.0, 0.0],
];
fn bern2(t: f32) -> [f32; 3] {
let s = 1.0 - t;
[s * s, 2.0 * s * t, t * t]
}
fn bezier_eval(ctrl: &[[f32; 3]; 9], u: f32, v: f32) -> [f32; 3] {
let bu = bern2(u);
let bv = bern2(v);
let mut acc = [0.0f32; 3];
for (j, &cv) in bv.iter().enumerate() {
for (i, &cu) in bu.iter().enumerate() {
let w = cu * cv;
let p = ctrl[j * 3 + i];
acc[0] += w * p[0];
acc[1] += w * p[1];
acc[2] += w * p[2];
}
}
acc
}
let samples = 4u32;
let scene = ObjDecoder::new()
.with_curve_tessellation(samples)
.decode(BSPLINE_QUADRATIC_CLAMPED.as_bytes())
.unwrap();
let prim = &scene.meshes[0].primitives[0];
let n = samples as usize + 1;
assert_eq!(prim.positions.len(), n * n);
for sv in 0..n {
let v = sv as f32 / (n - 1) as f32;
for su in 0..n {
let u = su as f32 / (n - 1) as f32;
let expect = bezier_eval(&ctrl, u, v);
let got = prim.positions[sv * n + su];
for k in 0..3 {
assert!(
(got[k] - expect[k]).abs() < 2e-3,
"clamped B-spline ≠ Bezier at (u={u}, v={v}) axis {k}: \
got {got:?}, expected {expect:?}"
);
}
}
}
}
const NURBS_SURFACE_SPEC_EX5: &str = "\
v -1.3 -1.0 0.0
v 0.1 -1.0 0.4 7.6
v 1.4 -1.0 0.0 2.3
v -1.4 0.0 0.2
v 0.1 0.0 0.9 0.5
v 1.3 0.0 0.4 1.5
v -1.4 1.0 0.0 2.3
v 0.1 1.0 0.3 6.1
v 1.1 1.0 0.0 3.3
cstype rat bspline
deg 2 2
surf 0.0 1.0 0.0 1.0 1 2 3 4 5 6 7 8 9
parm u 0.0 0.0 0.0 1.0 1.0 1.0
parm v 0.0 0.0 0.0 1.0 1.0 1.0
end
";
#[test]
fn nurbs_surface_interpolates_corners_with_clamped_knots() {
let samples = 6u32;
let scene = ObjDecoder::new()
.with_curve_tessellation(samples)
.decode(NURBS_SURFACE_SPEC_EX5.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1);
let prim = &scene.meshes[0].primitives[0];
assert_eq!(
prim.extras.get("obj:surface_kind").and_then(|v| v.as_str()),
Some("rat_bspline")
);
let n = samples as usize + 1;
assert_eq!(prim.positions.len(), n * n);
let c00 = prim.positions[0]; let c10 = prim.positions[n - 1]; let c01 = prim.positions[(n - 1) * n]; let c11 = prim.positions[n * n - 1]; assert!((c00[0] - -1.3).abs() < 2e-3 && (c00[1] - -1.0).abs() < 2e-3);
assert!((c10[0] - 1.4).abs() < 2e-3 && (c10[1] - -1.0).abs() < 2e-3);
assert!((c01[0] - -1.4).abs() < 2e-3 && (c01[1] - 1.0).abs() < 2e-3);
assert!((c11[0] - 1.1).abs() < 2e-3 && (c11[1] - 1.0).abs() < 2e-3);
}
const BSPLINE_CUBIC_SPEC_EX3: &str = "\
v -5.000000 -5.000000 -7.808327
v -5.000000 -1.666667 -7.808327
v -5.000000 1.666667 -7.808327
v -5.000000 5.000000 -7.808327
v -1.666667 -5.000000 -7.808327
v -1.666667 -1.666667 11.977780
v -1.666667 1.666667 11.977780
v -1.666667 5.000000 -7.808327
v 1.666667 -5.000000 -7.808327
v 1.666667 -1.666667 11.977780
v 1.666667 1.666667 11.977780
v 1.666667 5.000000 -7.808327
v 5.000000 -5.000000 -7.808327
v 5.000000 -1.666667 -7.808327
v 5.000000 1.666667 -7.808327
v 5.000000 5.000000 -7.808327
cstype bspline
deg 3 3
surf 0.0 1.0 0.0 1.0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
parm u -3.0 -2.0 -1.0 0.0 1.0 2.0 3.0 4.0
parm v -3.0 -2.0 -1.0 0.0 1.0 2.0 3.0 4.0
end
";
#[test]
fn cubic_bspline_surface_lies_in_control_hull() {
let samples = 8u32;
let scene = ObjDecoder::new()
.with_curve_tessellation(samples)
.decode(BSPLINE_CUBIC_SPEC_EX3.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1);
let prim = &scene.meshes[0].primitives[0];
let n = samples as usize + 1;
assert_eq!(prim.positions.len(), n * n);
assert_eq!(prim.indices.as_ref().unwrap().len(), 8 * 8 * 6);
for p in &prim.positions {
assert!(
p[0] >= -5.0 - 1e-3 && p[0] <= 5.0 + 1e-3,
"x out of hull: {p:?}"
);
assert!(
p[1] >= -5.0 - 1e-3 && p[1] <= 5.0 + 1e-3,
"y out of hull: {p:?}"
);
assert!(p[2] >= -7.81 && p[2] <= 11.98, "z out of hull: {p:?}");
}
let centre = prim.positions[(n / 2) * n + n / 2];
let corner = prim.positions[0];
assert!(
centre[2] > corner[2],
"interior should ride above the dished corners: centre {centre:?}, corner {corner:?}"
);
}
#[test]
fn bspline_surface_round_trips_directives_without_leaking_samples() {
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(BSPLINE_QUADRATIC_CLAMPED.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1, "synthetic surface mesh present");
let bytes = obj::serialize_obj(&scene, None).unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
let v_lines = text.lines().filter(|l| l.starts_with("v ")).count();
assert!(
v_lines <= 9,
"tessellation samples leaked as `v` lines; got {v_lines}:\n{text}"
);
assert!(
!text.contains("o obj:surfaces"),
"synthetic surface mesh must not be re-emitted as a polygonal block"
);
for keyword in [
"cstype bspline",
"deg 2 2",
"surf 0",
"parm u 0",
"parm v 0",
"end",
] {
assert!(
text.lines().any(|l| l.starts_with(keyword)),
"missing `{keyword}` line in:\n{text}"
);
}
}
#[test]
fn bspline_surface_with_short_knot_vector_is_skipped() {
let text = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 2.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
v 2.0 1.0 0.0
v 0.0 2.0 0.0
v 1.0 2.0 0.0
v 2.0 2.0 0.0
cstype bspline
deg 2 2
surf 0.0 1.0 0.0 1.0 1 2 3 4 5 6 7 8 9
parm u 0.0 0.0 1.0 1.0
parm v 0.0 0.0 0.0 1.0 1.0 1.0
end
";
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(text.as_bytes())
.unwrap();
assert!(
scene.meshes.is_empty(),
"knot-vector/control-count mismatch should skip tessellation"
);
assert!(scene.extras.contains_key("obj:freeform_directives"));
}
const CARDINAL_SURF_SPEC_EX4: &str = "\
v -5.000000 -5.000000 0.000000
v -5.000000 -1.666667 0.000000
v -5.000000 1.666667 0.000000
v -5.000000 5.000000 0.000000
v -1.666667 -5.000000 0.000000
v -1.666667 -1.666667 0.000000
v -1.666667 1.666667 0.000000
v -1.666667 5.000000 0.000000
v 1.666667 -5.000000 0.000000
v 1.666667 -1.666667 0.000000
v 1.666667 1.666667 0.000000
v 1.666667 5.000000 0.000000
v 5.000000 -5.000000 0.000000
v 5.000000 -1.666667 0.000000
v 5.000000 1.666667 0.000000
v 5.000000 5.000000 0.000000
cstype cardinal
deg 3 3
surf 0.000000 1.000000 0.000000 1.000000 13 14 15 16 9 10 11 12 5 6 7 8 1 2 3 4
parm u 0.000000 1.000000
parm v 0.000000 1.000000
end
";
#[test]
fn default_decoder_does_not_tessellate_cardinal_surfaces() {
let bare = ObjDecoder::new()
.decode(CARDINAL_SURF_SPEC_EX4.as_bytes())
.unwrap();
assert!(
bare.meshes.is_empty(),
"default decoder must not synthesise Cardinal surface meshes"
);
}
#[test]
fn cardinal_spec_example4_tessellates_into_a_flat_grid() {
let samples = 4u32;
let scene = ObjDecoder::new()
.with_curve_tessellation(samples)
.decode(CARDINAL_SURF_SPEC_EX4.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1, "one synthetic surface mesh expected");
let mesh = &scene.meshes[0];
assert_eq!(mesh.name.as_deref(), Some("obj:surfaces"));
assert_eq!(mesh.primitives.len(), 1);
let prim = &mesh.primitives[0];
assert_eq!(prim.topology, Topology::Triangles);
assert_eq!(prim.positions.len(), 25, "(samples + 1)^2 lattice vertices");
assert_eq!(prim.indices.as_ref().unwrap().len(), 96);
for p in &prim.positions {
assert!(p[2].abs() < 1e-4, "vertex off the z=0 plane: {p:?}");
}
assert_eq!(
prim.extras
.get("obj:tessellated_curve")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
prim.extras
.get("obj:tessellated_surface")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
prim.extras.get("obj:surface_kind").and_then(|v| v.as_str()),
Some("cardinal")
);
let deg = prim
.extras
.get("obj:surface_degree")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(deg[0].as_u64(), Some(3));
assert_eq!(deg[1].as_u64(), Some(3));
}
const CARDINAL_INTERIOR_BULGE: &str = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 2.0 0.0 0.0
v 3.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.5
v 2.0 1.0 0.7
v 3.0 1.0 0.0
v 0.0 2.0 0.0
v 1.0 2.0 0.9
v 2.0 2.0 0.3
v 3.0 2.0 0.0
v 0.0 3.0 0.0
v 1.0 3.0 0.0
v 2.0 3.0 0.0
v 3.0 3.0 0.0
cstype cardinal
deg 3 3
surf 0.0 1.0 0.0 1.0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
parm u 0.0 1.0
parm v 0.0 1.0
end
";
#[test]
fn cardinal_surface_interpolates_interior_control_points() {
let samples = 6u32;
let scene = ObjDecoder::new()
.with_curve_tessellation(samples)
.decode(CARDINAL_INTERIOR_BULGE.as_bytes())
.unwrap();
let prim = &scene.meshes[0].primitives[0];
let n = samples as usize + 1;
assert_eq!(prim.positions.len(), n * n);
let p00 = prim.positions[0];
let p10 = prim.positions[n - 1];
let p01 = prim.positions[(n - 1) * n];
let p11 = prim.positions[n * n - 1];
let approx = |a: [f32; 3], b: [f32; 3]| {
(a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4 && (a[2] - b[2]).abs() < 1e-4
};
assert!(approx(p00, [1.0, 1.0, 0.5]), "(0,0) corner: {p00:?}");
assert!(approx(p10, [2.0, 1.0, 0.7]), "(1,0) corner: {p10:?}");
assert!(approx(p01, [1.0, 2.0, 0.9]), "(0,1) corner: {p01:?}");
assert!(approx(p11, [2.0, 2.0, 0.3]), "(1,1) corner: {p11:?}");
}
#[test]
fn cardinal_surface_matches_cardinal_to_bezier_reference() {
let grid: [[f32; 3]; 16] = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[3.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[1.0, 1.0, 0.5],
[2.0, 1.0, 0.7],
[3.0, 1.0, 0.0],
[0.0, 2.0, 0.0],
[1.0, 2.0, 0.9],
[2.0, 2.0, 0.3],
[3.0, 2.0, 0.0],
[0.0, 3.0, 0.0],
[1.0, 3.0, 0.0],
[2.0, 3.0, 0.0],
[3.0, 3.0, 0.0],
];
fn cardinal_seg(c: &[[f32; 3]; 4], t: f32) -> [f32; 3] {
let mut b = [[0.0f32; 3]; 4];
for a in 0..3 {
b[0][a] = c[1][a];
b[1][a] = c[1][a] + (c[2][a] - c[0][a]) / 6.0;
b[2][a] = c[2][a] - (c[3][a] - c[1][a]) / 6.0;
b[3][a] = c[2][a];
}
let u = 1.0 - t;
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
let mut p = [0.0f32; 3];
for a in 0..3 {
p[a] = w[0] * b[0][a] + w[1] * b[1][a] + w[2] * b[2][a] + w[3] * b[3][a];
}
p
}
fn reference(grid: &[[f32; 3]; 16], u: f32, v: f32) -> [f32; 3] {
let mut col = [[0.0f32; 3]; 4];
for (r, slot) in col.iter_mut().enumerate() {
let row: [[f32; 3]; 4] = [
grid[r * 4],
grid[r * 4 + 1],
grid[r * 4 + 2],
grid[r * 4 + 3],
];
*slot = cardinal_seg(&row, u);
}
cardinal_seg(&col, v)
}
let samples = 5u32;
let scene = ObjDecoder::new()
.with_curve_tessellation(samples)
.decode(CARDINAL_INTERIOR_BULGE.as_bytes())
.unwrap();
let prim = &scene.meshes[0].primitives[0];
let n = samples as usize + 1;
for sv in 0..n {
let v = sv as f32 / (n - 1) as f32;
for su in 0..n {
let u = su as f32 / (n - 1) as f32;
let expect = reference(&grid, u, v);
let got = prim.positions[sv * n + su];
for k in 0..3 {
assert!(
(got[k] - expect[k]).abs() < 1e-4,
"Cardinal surface ≠ reference at (u={u}, v={v}) axis {k}: \
got {got:?}, expected {expect:?}"
);
}
}
}
}
#[test]
fn cardinal_surface_round_trips_directives_without_leaking_samples() {
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(CARDINAL_SURF_SPEC_EX4.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1, "synthetic surface mesh present");
let bytes = obj::serialize_obj(&scene, None).unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
let v_lines = text.lines().filter(|l| l.starts_with("v ")).count();
assert!(
v_lines <= 16,
"tessellation samples leaked as `v` lines; got {v_lines}:\n{text}"
);
assert!(
!text.contains("o obj:surfaces"),
"synthetic surface mesh must not be re-emitted as a polygonal `o` block"
);
for keyword in ["cstype cardinal", "deg 3 3", "surf 0", "end"] {
assert!(
text.lines().any(|l| l.starts_with(keyword)),
"missing `{keyword}` line in:\n{text}"
);
}
}
#[test]
fn non_cubic_cardinal_surface_is_rejected() {
let text = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 2.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
v 2.0 1.0 0.0
v 0.0 2.0 0.0
v 1.0 2.0 0.0
v 2.0 2.0 0.0
cstype cardinal
deg 2 2
surf 0.0 1.0 0.0 1.0 1 2 3 4 5 6 7 8 9
parm u 0.0 1.0
parm v 0.0 1.0
end
";
let scene = ObjDecoder::new()
.with_curve_tessellation(4)
.decode(text.as_bytes())
.unwrap();
assert!(
scene.meshes.is_empty(),
"non-cubic Cardinal surfaces stay captured-only"
);
assert!(scene.extras.contains_key("obj:freeform_directives"));
}
const CARDINAL_MULTISEG: &str = "\
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 2.0 0.0 0.0
v 3.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 1.0 0.0
v 2.0 1.0 0.0
v 3.0 1.0 0.0
v 0.0 2.0 0.0
v 1.0 2.0 0.6
v 2.0 2.0 0.6
v 3.0 2.0 0.0
v 0.0 3.0 0.0
v 1.0 3.0 0.0
v 2.0 3.0 0.0
v 3.0 3.0 0.0
v 0.0 4.0 0.0
v 1.0 4.0 0.0
v 2.0 4.0 0.0
v 3.0 4.0 0.0
cstype cardinal
deg 3 3
surf 0.0 2.0 0.0 1.0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
parm u 0.0 1.0 2.0
parm v 0.0 1.0 2.0 3.0
end
";
#[test]
fn cardinal_multi_segment_surface_tessellates() {
let samples = 4u32;
let scene = ObjDecoder::new()
.with_curve_tessellation(samples)
.decode(CARDINAL_MULTISEG.as_bytes())
.unwrap();
assert_eq!(scene.meshes.len(), 1, "multi-segment Cardinal surface");
let prim = &scene.meshes[0].primitives[0];
let n = samples as usize + 1;
assert_eq!(prim.positions.len(), n * n);
assert_eq!(prim.indices.as_ref().unwrap().len(), 96);
let max_z = prim.positions.iter().map(|p| p[2]).fold(f32::MIN, f32::max);
assert!(
max_z > 0.3,
"interior bulge should lift the surface above z = 0; max_z = {max_z}"
);
for p in &prim.positions {
assert!(p[0] >= -0.5 && p[0] <= 3.5, "x off the net: {p:?}");
assert!(p[1] >= -0.5 && p[1] <= 4.5, "y off the net: {p:?}");
}
}